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. There are no added semantics or extra standard library. It's just Erlang. Technically, 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 special 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.

Run tele help to see that it is working.

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.

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!

Getting Started

If you have Tele installed you'll want to do more than hello_world.

In this section we will show you how to setup an Erlang library and write the code with Tele.

Create A New Project

Use Rebar3 to create a new Erlang project.

rebar3 new lib example

There will be a few files of note. You can read more about rebar.config and src/example.app.src in the Rebar3 docs.

Since we are coding in Tele we can go ahead and delete src/example.erl and make a new tele file in it's place.

rm -f src/example.erl
touch src/example.tl

Inside of src/example.tl make a module like this:

fun add2(x): x + 2

Run tele build. Then start the shell, rebar3 shell.

In the shell we can run our module:

example:add2(4).
=> 6

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.

Bit Syntax

Tele supports Erlang's bit syntax for binaries. Refer here for detailed documentation.

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

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

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.

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])

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".

Guards

Guards are modifiers for pattern matching. Let's see an example with a function definition.

fun foobar
  (x) when x > 2: 0
  (x): x + 2

Notice the when keyword as part of the function definition pattern matching for the first clause. This means that any number greater than two will return a result of zero. Anything else will be that number plus two.

Now to see it in action:

[3, 4, 0, 0, 0] = lists.map(fun foobar/1, [1, 2, 3, 4, 5])

Guard Sequences

While this is rare, there are times when you might want to do multiple guards for a single pattern match. For this scenario we can use guard sequences. The syntax for this leverages block syntax rules with the when keyword.

fun foobar (x, y) when x > 2
                  when y < 3:
  42

Note that the newline is required to separate the when blocks.

This is a derived example. You would probably prefer something like this instead:

fun foobar(x, y) when x > 2 and y < 3: 42

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

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.

Exception syntax looks a bit like module.function. It is class.exception_pattern. Sometimes the class might not be known ahead.

If we want to catch any exception:

catch:
  _._: 'ok

Or if we want to catch any throw exception and return the exception pattern using an absolute variable:

catch:
  throw.@err: @err

There are lots of variations you can do, but this shows how to treat the components of the exception as separable variables to match with.

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.

After writing specs for your functions you can use dialyzer to check them for you.

tele build
rebar3 dialyzer

Types

Type Expressions

Erlang comes with a variety of builtin types.

With Tele parentheses are optional for type expressions. For example the integer type can be represented like this:

integer()

OR

integer

Type Parameters

Some types can take other types as parameters for more specificity. For example the list type:

list

Can be more specific to be a list of integers:

list(integer)

Type Definitions

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)

Type Variables

Since Tele uses lowercase letters for both types and variables it can make type variables ambiguous. This is solved by using an absolute variable.

Let's see how we can define a type with a type variable and use it.

Let's make a result type like so:

type result(@data): #('ok, @data) | #('error, any)

This is a type that can either be an ok tuple with some specified type or an error tuple with any type.

Let's see how we can use it for a function spec:

spec some_api_call(): result(list(map))
fun some_api_call():
  call_some_api()

Here we are specifying that the function some_api_call will return an okay tuple with a list of maps or an error tuple.

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.

Macros

Tele supports Erlang Macros. The definition and usage is similar as well.

You can read more about Erlang macros here.

Macros are used for a few purposes. One of them is like a constant:

define MAGIC_NUMBER: 42

Then when we want to use that macro it looks like this:

fun math_stuff():
  ?MAGIC_NUMBER + 2

Macros can also take parameters like a function.

Let's say we had some tuple shape we like but want to determine that static values later:

define MAGIC_TUPLE(a, b):
  #('a, a, 'b, b)

We can use this macro like this:

fun tuple_stuff():
  ?MAGIC_TUPLE(1, 2)

Would expand to:

fun tuple_stuff():
  #('a, 1, 'b, 2)

There are a number of uses for macros, but keep in mind these macros are less powerful than say Lisp's or Elixir's.

Header Files

Header files are useful for reusable types, records, and macros.

Header files will normally go into the include directory.

Tele header files use the .htl extension. Tele supports including header files with this extension.

NOTE: If you are using a header file written in Tele in an Erlang module make sure to use the .hrl extension.

An example header file might looks like this:

record point: #(x, y)

type ids: list(integer)

define PI: 3.14

Only certain statements like records, types, and macros are supported in header files.

Builtin Attributes

import

import foobar(
  some_fun/1, 
  some_fun/2
)

Read More

behaviour

behaviour(gen_server)

Read More

callback

callback foo(integer): integer

Read More

include

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

Read More

include_lib

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

Read More

export_type

export_type(id/0)

Read More

nifs

nifs(foo/2, bar/3)

Read More

doc

doc("this is a doc attribute")

Read More

moduledoc

moduledoc("this is a module doc.")

Read More

on_load

on_load(some_function/0)

Read More

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.