Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Tele is an alternative syntax for the Erlang programming language.

This means that Tele code is compiled to Erlang code while being fully interoperable. There are no added semantics or extra standard library. It's just Erlang. In fact, the syntax is a subset of Erlang's.

The design is intended to be transparent to end users. Meaning libraries or tools that one writes in Tele will be seen as Erlang to the end user.

Who is this for?

Tele is meant to be an easy functional programming language to learn with a minimal syntax so it can be accessible for a wide audience. The goal of the language was take the good parts of Erlang and make it feel more "modern" to appeal to people coming from Python or Javascript.

However, due to current tooling limitations Tele is probably best suited for developers who have some familiarity with Erlang.

You might like this if you are an Erlang programmer who:

  • Finds it hard to convince your friends or colleagues to code in Erlang because they don't like the Prolog style syntax.
  • Are annoyed by the constant syntax errors caused by missing commas, semicolons, periods, etc.

Goals

  • Easy to learn
  • Enjoyable to read
  • Minimize use of characters to reduce syntax errors
  • Consistent but willing to sacrifice grammatical purity for user friendliness

Installation

Install Binary

Download the binary for your OS and Architecture here.

Rename the binary to tele and put it in your path.

Build From Source

Install Zig 0.14 from here.

Download the Tele code.

cd tele/tele
zig build

The binary will be at zig-out/bin/tele.

Dependencies

The Tele binary only compiles Tele code to Erlang code. To actually run the code or do anything useful you will need Erlang and the build tool Rebar3.

Install Erlang/Rebar3:

Install Erlang from here.

Install Rebar3 from here.

Hello World

Let's make sure everything is working by making a simple 'Hello, World!' program.

Make a file called hello.tl:

fun hello_world():
  io.format("Hello, World!~n", [])

Then we compile the program:

tele compile hello.tl

This will make a file hello.erl:

-module(hello).
-export([hello_world/0]).

hello_world() ->
    io:format(<<"Hello, World!~n"/utf8>>, []).

We can fire up the Erlang REPL and call the function of this Erlang module we made:

Start the REPL like this:

erl

Compile the Erlang module hello.erl.

c(hello).

Then call our hello_world method from the hello module.

hello:hello_world().
=> Hello, World!

```

Numbers

Tele's number data types are taken from Erlang. To read more about Erlang's number datatypes go here.

Integers

1
2
43

Underscore's are allowed in integers:

1_000_000

In tele, characters are also integers. They are represented like this:

$h
$e
$l
$l
$o

Numbers with different bases can also be represented:

16#1f

Floats

0.1
42.42

Scientific notation is also supported:

2.3e3
2.3e-3

Strings

Tele has a basic "string" datatype that looks like this:

"Hello, World!"

While this looks like a string in other programming languages, under the hood it is actually what we call a binary.

In simple term's it is basically a static string. There is more nuance to them that, but for now keep it simple.

When the above string gets compiled to Erlang it gets compiled to this:

<<"Hello, World!"/utf8>>

This sane default is actually taken from Elixir.

Erlang String's

What can be confusing is that Erlang has it's own string datatype. This string is a list of characters. In practice, the binary is more in line with the popular notion of static string hence why it is preferred by tele.

If you really need an Erlang string (a list of characters) you can always do this:

binary_to_list("Hello, World!")

When coding in Tele keep this in mind because while they look like the strings you might be familiar with in Javascript and Python they have different semantics at times.

Atoms

Atom's are data type literals. Tele borrows the orginal Lisp syntax for atoms with a little bit of extra.

They look like this:

'foo

Since it is common for autocomplete to wrap single quotes when making an atom, a surrounding quote is also supported:

'foo'

Keep in mind that two single quotes will not work with spaces as it does in Erlang.

You can't do this:

'foo bar'

This is because the Tele tokenizer uses spaces or newlines to delimit the end of the atom in the case when only one single quote is used.

However, there is a special syntax to support this case:

#'foo bar'

TODO: Don't support this case?

One can always do this:

binary_to_atom("foo bar")

Booleans

Atom's are also how booleans are represented. Simply use these atoms:

'true
'false

Lists

Tele's lists have the exact same syntax as Erlang.

[1, 2, 3]

In functional languages it is common to "cons" or make a new list with an element prepended to the original.

The syntax for that looks like this:

l = [2, 3]
l2 = [1 | l]

l2 will now look like this:

[1, 2, 3]

We can cons multiple elements as well:

l = [4, 5, 6]
[1, 2, 3 | l]

Maps

Maps in Erlang use the familiar JSON notation for dictionaries and objects:

An empty map looks like this:

{}

A map with keys and values looks like this:

{"key": "value"}

Map Update

There is syntax to update a map:

m = {"key": "value"}
m2 = {m | "key2": "value2"}

It's possible to update multiple keys at a time as well:

m2 = {m | "key": "new_value", 
          "key2": "value2",
          "key3": "value3"}

This syntax was inspired by Elm's record update syntax.

Tuples

Tuples are a static array of values. While they might not be used as much in other languages, they are conventionally very common in Tele or Erlang.

The syntax for tuples looks like this:

#(... , ...)

This syntax is actually taken from Gleam!

Ok/Error tuple

A common convention is to return a result as a tuple with an 'ok atom or an 'error atom.

ok:

#('ok, some_value)

error:

#('error, 'error_message)

The different tuples can be handled with a case statement:

case some_function():
  #('ok, v): v
  #('error, _err) = e: e

Here we return the variable v if some_function returns an ok tuple, and we return the error tuple itself in that condition.

Records

Records in Tele have a similar syntax as tuples. This is appropriate because records are semantically just named tuples.

Definitions

record point: #(x, y)

You can define records with types:

record point: #(x: integer, y: integer)

And even default values:

record point: #(x = 0, y = 0)

Putting it all together:

record point: #(x: integer = 0, y: integer = 0)

Record definitions use block syntax so to put the definition on multiple lines we can do something like this:

record point: 
  #(
    x: integer = 0,
    y: integer = 0 
  )

Instantiating

Once a record is defined we can use this syntax to instantiate a new record:

#point(x = 42, y = 43)

If we only want to set some of the fields we can:

#point(x = 42)

Updating

To update a field in a record we need to assign our record to a variable first.

This creates a point record with no fields defined. If we set our default values like we did before then x and y are both 0.

p = #point()

To update p we do this:

p2 = p#point(x = 24)

Accessing Fields

After we instantiate a record we will need to get access to its fields. Make sure it is assigned to a variable. Then we can do this:

p = #point(x = 42, y = 42)

p#point.x

Notice we had to include the record type in the name. That's because the Erlang compiler needs to know what type of record it is for the field access to work.

Variables

Variables in Tele are conventionally lowercase and can include underscores.

Example:

a = 2
hello_world = "Hello, World!"

Absolute Variables

There are some edge cases in the syntax where it is ambiguous if it is a variable or not. To ensure the compiler knows we are using a variable Tele introduces the idea of an absolute variable. Think of it like we want the compiler to be absolutely sure that value is a variable.

Absolute variables can be used anywhere a variable is used. Later in this book there will be shown actual uses of absolute variables.

For now you only need to that they exist and they look like this:

@a = 2

Match Operator

In most programming languages, = means assignment. In Erlang, and therefore Tele, = is the match operator.

Pattern matching is an important aspect of Tele (Erlang).

Let's say we make a variable like this:

a = 2

Then we try to do this:

a = 3

We will get an error like this:

** exception error: no match of right hand side value 3

Not only are the variables immutable, but the match fails because a is already 2. It worked the first time because a did not have a value yet.

Pattern matching works for things like data structures as well.

This works:

[1, 2, 3] = [1, 2, 3]

This doesn't:

[1, 2, 3] = [4, 5, 6]

It will throw this error message:

** exception error: no match of right hand side value [4,5,6]

Destructuring

We know that pattern matching works at assigning values to variables and it works on data structures. It is a common pattern to access values in a data structure. We can do this in Erlang in a few ways depending on the data structure.

Let's say we have a list like this:

l = [1, 2, 3]

And we want to get the first value of the list:

[head | tail] = l

This is a very common pattern in functional programming languages using the cons operator.

head = 1

and

tail = [2, 3]

If we wanted to just get the head and ignore the tail we can use the _ to ignore it.

[head | _] = l

or

[head | _tail] = l

Sometimes that can be helpful to describe the value we want to ignore.

Lists are a bit special with their use of the cons operator, but we will see how tuples and maps are even easier to match on.

An example with a tuple might look like this:

t = #('ok, 42)

#('ok, v) = t

v = 42

Here we see that we are matching t to a tuple with the atom 'ok and the variable v. The 'ok atom is important because if were to do something else then the pattern match would fail.

#('error, v) = t

Would throw an error.

Finally maps work similarly. Here is an example of how to destructure a map:

m = {"hello": "world", "foo": "bar"}

{"hello": world, "foo": some_value} = m

world = "world"

some_value = "bar" 

We could even just grab one key value pair instead of all of it:

{"hello": world} = m
world = "hello"

This works because m matches on a map with the field "hello".

Function Definitions

Define a function using the fun keyword.

fun hello_world(): io.format("Hello, World!")

A function signature can have params:

fun add(a, b): a + b

A function body can be many lines:

fun some_math(x, y):
  z = x + y
  d = z + 42
  d

Function definitions use block syntax. This means that this would be invalid function definition:

fun add(a, b):
a + b

The body of the function needs to be 1 column ahead of the starting column of fun (in simple terms the f).

Tele like Erlang, Lisp, Ruby, or Rust has an implicit return with the last value in the function body.

Tele also supports pattern matching in the signature. For example we can implement factorial like this:

fun factorial
  (0): 1
  (n): n * factorial(n - 1)

This means when factorial is called with 0 it returns 1. If it is called with any value besides 0 it multiplies that number by the recursive call of factorial with n subtracted by 1.

This can be helpful if we want to pattern match on different results. A common pattern might look like this:

fun handle_result
  (#('ok, v)): #('ok, v + 2)
  (#('error, _err) = e): e

Here if handle_result is called with an ok tuple we add 2 to the value. In the case it is called with an error tuple then we simply return that entire tuple.

Anonymous Functions

In Tele anonymous functions look exactly like function definitions without a name (hence anonymous).

For example:

z = 42
f = fun (a): a + z

Since f is a variable we need to tell the Tele compiler we are doing a function call on a variable not a function defined as f. We can use an absolute variable to do that.

output = @f(2)
output = 44

If we tried:

output = f(2)

We would get an error about how the function f isn't found.

Anonymous functions are values and can be passed into other functions. A common example is using a function to map over a list:

[3, 4, 5] = lists.map(fun (x): x + 2, [1, 2, 3])

Or if we wanted to indent it:

[3, 4, 5] = lists.map(
  fun (x):
    x + 2,
  [1, 2, 3]
)

Pattern matching is also possible with anonymous functions.

We could mix up the example above like this:

[0, 777, 5] = lists.map(
  fun
    (1): 0
    (2): 777
    (x): x + 2,
  [1, 2, 3]
)

Dynamic Function Calls

Sometimes in Tele we want to do wild things like passing a function an atom that is then used to do a function call.

Imagine we have a list of function definitions like this:

fun foobar(): 7

fun barfoo(): 8

fun do_thing(): [1, 2, 3]

In this scenario we also might not know ahead of time how we want to call these functions. So we can figure it out at runtime.

fun caller(f):
  @f()
[7, 8, [1, 2, 3]] = lists.map(fun caller/1, ['foobar, 'barfoo, 'do_thing])

Case

Tele uses case statements to handle conditional statements. Think of it as a switch statement/if-else statement on steroids.

Let's see an example:

case some_function():
  1: 'one
  2: 'two
  3: 'three
  _: 'undefined

Here a function some_function is called that might return 1, 2, 3, or something else.

The equivalent to an if statement in Tele is something like this:

case 1 =:= 1:
  'true: 'ok
  'false: #('error, 'math_broken)

Equivalence with Function Definitions

A key thing to know about Tele is that case statements are equivalent to pattern matching with function definitions.

This case statement:

case some_variable:
  1: 'one
  2: 'two
  3: 'three
  _: 'undefined

Is equivalent to this:

fun handle_result
  (1): 'one
  (2): 'two
  (3): 'three
  (_): 'undefined

We can handle some_variable with the function call:

handle_result(some_variable)

Operators

Possible operators:

Match

=

Equality

=:=
==

Not equal:

=/=
/=

Less than/Less than or equal:

<
<=

Greater than/Greater than or equal:

>
>=

Addition:

+

Subtraction or negative:

-

Multiplication:

*

Division:

/

Concatenation:

++

Cons or Map Update:

|

Try/Catch

In Tele there is a typical convention of returning a tuple as a result to signify an error or not. This is a bit like the Result type in Rust.

For example some function foobar. Might return a result like this:

#('ok, v) = foobar()

OR

#('error, err) = foobar()

However, there are times when actual exceptions can happen. There are times when we might want to catch those exceptions for a variety of reasons. For that we use a try/catch expression. A try must be followed by a catch. An example might look like this:

try maybe_result():
  #('ok, _v) = foo: foo
catch:
  _._: #('error, 'oops)

Notice that the body of the try is pattern matching on the successful result of maybe_result. However we could match on other results:

try maybe_result():
  #('ok, 42): #('ok, 'right_number)
  #('ok, 43): #('ok, 'almost_right_number)
  _: #('error, 'undefined)

Here we are matching on an ok tuple that contains the number 42, or the number 43. If nothing matches we return an error tuple with the 'undefined atom. This works exactly like a case statement.

The catch expression works in a similar way except it matches on exceptions.

TODO: Describe exceptions in more detail

Specs

In Tele we can specify types for our functions using specs.

For example:

fun add(a, b): a + b

We can add a spec for it like this:

spec add(integer, integer): integer
fun add(a, b): a + b

This means it takes two arguments of type integer and returns an integer.

Type Expressions

In tele we can define our own types:

type point: #(integer, integer)

This defines a type called point that is a tuple with two integers.

We can use it in a a function spec.

spec move_up(point): point
fun move_up(#(x, y)): #(x, y - 1)

Exporting Functions

To make a Tele module simply make a .tl file in your project.

Like Elixir, functions are exported by default. Let's make a simple module called math.tl:

fun add(a, b): a + b
fun sub(a, b): a - b
fun mul(a, b): a * b
fun div(a, b): a / b

Then we can make another module that uses these functions. We can call it math_stuff.tl. To use a function from another module we can simply prefix the function name with the module like this:

module.function_name()

So if we wanted to use functions from the math module we just made we can do it like this:

fun do_math():
  x = math.add(1, 2)
  y = math.sub(x, 4)
  z = math.mul(y, 6)
  math.div(z, 3) 

This same idea works for modules written in Erlang. This works because Tele modules are Erlang modules.

For example if we wanted to use a method from Erlang's lists module in the standard library it might look like this:

spec do_map(list): list
fun do_map(l):
  lists.map(fun (x): x + 2, l)

This works both ways. So if we wanted to use the math module in an Erlang module, it might look like this:

-module(math_stuff).
-export([do_math/0]).

do_math() ->
    X = math:add(1, 2),
    Y = math:sub(X, 4),
    Z = math:mul(Y, 6),
    math:div(Z, 3).

If you are familiar with Erlang you can see that you would have no idea if the math module was written in Tele or Erlang.

Importing Functions

The easiest way to use a function from a module is simply use the module.function syntax.

Sometimes the name of the module might be long and used in several places. Let's say we were working on a web app using the nine library. We might be calling the json_response function a lot.

Our module might look like this:

fun handle_get_todos({'req: req} = c):
  todos = [{"id": 1, body": "Go to the store"},
           {"id": 2, body": "Wash the dishes"}]
  nine_cowboy_util.json_response(200, todos, req)

Perhaps this gets tiring after a while so we can simply import the function:

import nine_cowboy_util(json_response/3)

Then we can rewrite our handle_get_todos function like this:

fun handle_get_todos({'req: req} = c):
  todos = [{"id": 1, "body": "Go to the store"},
           {"id": 2, "body": "Wash the dishes"}]
  json_response(200, todos, req)

See how we didn't need to use the module prefix for the function call.

Private Functions

Sometimes when you make a module you don't want to export internal functions for a variety of reasons.

To prevent a function from being exported simply use funp. To see this in action lets continue our web app example.

We will make a private function to get our list of todos.

fun handle_get_todos({'req: req} = c):
  todos = get_todos()
  json_response(200, todos, req)

funp get_todos():
  [{"id": 1, "body": "Go to the grocery store"},
   {"id": 2, "body": "Wash the dishes"}]

In this example we need to export handle_get_todos so it can be used by the router to serve web requests. However, we don't need to export our get_todos method as that can be internal to the module.

Builtin Attributes

import

import foobar(
  some_fun/1, 
  some_fun/2
)

behaviour

behaviour(gen_server)

callback

callback foo(integer): integer

include

include("test/lib/test.hrl")

include_lib

include_lib("test/lib/test.hrl")

export_type

export_type(id/0)

nifs

nifs(foo/2, bar/3)

doc

doc("this is a doc attribute")

moduledoc

moduledoc("this is a module doc.")

onload

onload("something")

Custom Attributes

Sometimes there are attributes that Tele does not have builtin. These might come from libraries or a new feature from Erlang not yet supported.

This can be supported by using the attr keyword.

If the Erlang attribute looks like this:

-some_attr(hello)

The Tele equivalent would be:

attr some_attr('hello)

Notice we hade to use the atom syntax for 'hello. This is because we have to be explicit with the syntax.

The builtin attributes can work around this with syntax sugar.

Unit Tests

Tele leverages Erlang's unit test framework called eunit.

Let's say we have a module with this function:

fun add2(x): x + 2

This is a function that adds 2 to the given input value.

Now we want to make a unit test for it.

Here we will introduce the test attribute block.

Anything under this block will only be compiled during the testing stage.

Let's add the eunit header.

test:
  include_lib("eunit/include/eunit.hrl")

Eunit works by checking functions with the _test suffix in the name.

Let's make our unit test function.

test:

  include_lib("eunit/include/eunit.hrl")

  fun add2_test():
    ?assertEqual(4, add2(2))

Now run tele test to run the unit tests. The ?assertEqual macro is provided by the header we included. It provides some useful error messages when testing.

Named Test Blocks

Instead of making functions with the _test suffix we can use named test blocks. This is a syntax sugar that Tele provides.

So let's change the example above to look like this:

test:
  include_lib("eunit/include/eunit.hrl")

test add2:
  ?assertEqual(4, add2(2))

This feature is inspired by Zig's unit testing approach. A benefit of this is that we can put the unit test right next to the function we are testing. So the Tele way of writing this module would look like this:

test:
  include_lib("eunit/include/eunit.hrl")

fun add2(x): x + 2

test add2:
  ?assertEqual(4, add2(2))
  ?assertEqual(5, add2(3))

fun add3(x): x + 3

test add3:
  ?assertEqual(4, add3(1))
  ?assertEqual(5, add3(2))

In the above example we added another function add3 to stress the point of how to organize functions with their associated unit tests.