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

Language tour

Kestrel is a statically typed, pure, functional programming language that compiles to js. More specifically:

  • Statically typed: Kestrel has a sound type system (program compiles => no runtime errors) with full type inference (you can write functions without having to annotate their parameters). Errors are modeled as data, thus they are tracked by the type system.
  • Pure: Every function has no side effects, like mutation, or randomness, or IO. Side effects are modeled as data, thus they're tracked by the type system, and can be passed around and composed.
  • Functional: Because of the previous point, there are no loops: recursion is the only looping construct. Functions are first-class values, and every construct (if, match, fn, etc) behaves like expression.

It's designed to be simple and minimal: you can learn the whole language in under one hour, and you can master it within a weekend.

Basic datatypes

The most basic form of an expression are literals, such as:

// line comments begin with "//"

// values of type `Char`
'x'

// values of type `String`
"abc"

// values of type `Int`
42

// values of type `Float`
42.0
42.2

Other commonly used values are:

// Values of type `Bool`:
True
False

// `Unit`, the only value of type `Unit`:
Unit

// Tuples:
Tuple2("a", "b")
Tuple3("a", "b", "c")
// Tuples can be written using the tuple syntax:
("a", "b")
("a", "b", "c")

There are a bunch of pre-defined operators:

1 + 2 // => 3

// You have to use +. instead of + for floats:
1.5 +. 2.5 // => 4

"Hello " ++ "world" // => "Hello world"

!True // => False
True && False // => False

You can find all the available functions in the kestrel_core module documentation.

Types

Concrete types must always begin with an uppercase letter (like Int, Float, or String). Type parameters are passed via angle brackets (e.g. List<Int>).

Tuples can be written using the special notation (X, Y) instead of Tuple(X, Y).

Function types are written with the (A, B) -> C syntax.

Underscore types mean type variables. Unlike generics in languages like typescript, type variables are implicitly universally quantified. For example, the type of the identity function is (a) -> a.

In the example above, a is a type variable that can be instantiated to any possible type, as long as every occurrence is instantiated to the same type. This is roughly the same as writing <T>(x: T) => T in typescript.

Functions

You can create anonymous functions with the fn keyword:

fn x, y { x + y }

And call them using common function call syntax: my_function(1, 2)

Functions are first class citizen, therefore you can assign them to let declarations, pass them to other function or return from functions

Functions can have zero parameters as well:

fn { 42 }

but that should be pretty rare, as functions don't perform any side effects. You may still want to use zero-params functions for cases like lazy thunks

The compiler will warn you if you have unused parameters. To prevent that, you can use _ or a parameter that begins with _:

fn _, _ { 42 }

This is true for every other kind of local variable.

If expressions

To branch programs, you can use if expressions:

if n == 0 {
  "zero"
} else {
  "not zero"
}

Unlike typescript, there's no concept of boolean casting: the tested value has to be a Bool. This prevents all the javascript quirks of complex implicit casting rule, and undesired false positives in typechecking.

You can nest if-else expressions using an else if syntax as well:

if n == 0 {
  "zero"
} else if n == 1 {
  "one"
} else {
  "not sure"
}

⚠️ NOTE: nested else-if sugar isn't implemented yet

Let bindings

You can bind an expression to a variable using a let statements, so that it can be referenced by other expressions:

let x = 42

let y = x + 1

let declarations are immutable: they cannot be reassigned again (and they can only be defined once per module).

Since expressions have no side effects, you can order them however you want. In other words, you can also rewrite the previous example this way:

// it's completely legal to reference "x" before it's defined
let y = x + 1

let x = 42

Type hints

⚠️ NOTE: Let bindings are currently still using the let x: Type = ... syntax.

You can optionally write explicit types for global let bindings:

@type (a, b) -> a
let take_first = fn a, b { a }

Local bindings

You can introduce (immutable) local bindings by creating a block using {}.

// evaluates to 1 + 10*10 == 101
let declaration = 1 + {
  let x = 10;
  x * x
}

Note that each let statement has to end with a ;, whereas it's illegal for the returning expression to end with a ;.

let expressions are immutable too, but can be shadowed:

let declaration = {
  let x = 0;
  let x = 1; // shadows the first "x"
  x // evaluates as "1"
}

Note that shadowing is not the same thing as mutation. You can see it more clearly within closures:

// this evaluates as 0, not 1.
let my_f = {
  let x = 0;
  let f = fn { x }
  let x = 1;
  f
} ()

Local bindings can also defined within the implicit blocks created by the fn or if expressions.

Structs

⚠️ NOTE: the current syntax to define structs is still type X struct { .. }. This will be changed soon.

You can also define, create and manipulate structs:

// First, you need to define the type
struct Person {
  name: String,
  age: Int,
}

// you can then instantiate it:
let p = Person {
  name: "John Doe",
  age: 42
}

// you can access fields of a struct:
let age = p.age

// and you can perform immutable updates using the spread syntax:
let older_person = Person {
  age: p.age + 1,
  ..p
}

Structs can have type parameters:

struct Box<a> {
  inner: a,
}

Optional fields

⚠️ NOTE: optional fields aren't implemented yet.

You can use the ? modifier on a struct definition to make it optional:

struct Person {
  name: String,
  age?: Int,
}

// this way, you can omit the `age` field
let p = Person {
  name: "John Doe",
}

// inferred as: `Option<Int>`
let age = p.age

// you can still pass the field normally:
let p2 = Person {
  name: "John Doe",
  age: 42,
}

In case you need to dynamically decide not, whether to pass the value or, you can use the ? modifier to pass an Option:

let p = Person {
  age?: Some(42),
  // ..
}

Enums

⚠️ NOTE: the current syntax to define enums is still type X { .. }. This will be changed soon.

You can also create your own types, specifying its constructors:

enum MyType {
  A,
  B(Int, Int),
}

In addition to creating a type called MyType, we can also use its constructors:

// inferred as `MyType`
let x = A

// inferred as `MyType`
let y = B(0, 1)

Note: unlike languages like Ocaml, constructors are first class values. For example, in this case, B is a function of type (Int, Int) -> MyType

Custom types can have generic args:

enum Box<a> {
  MakeBox(a),
}

Notable types

There are a few types that are implemented as enums in the standard library instead of being built-in:

Pattern matching

Pattern matching is a fundamental construct, which allows to "deconstruct" values by making sure every possible variant is checked.

You can pattern match values using the match expression:

match value {
  None => "no values",
  Some(value) => "value is: " ++ value
}

The compiler will make sure we won't forget to check every possible combination.

Pattern match supports nested patterns, catchall patterns (_), and matching literals, e.g.

match (n % 3, n % 5) {
  (0, 0) => "FizzBuzz",
  (0, _) => "Fizz",
  (_, 0) => "Buzz",
  _ => String.from_int(n),
}

You can also use pattern matching in every other local binding context:

// blocks
{
  let Box(value) = f(),
  value
}

// fn params
fn Box(value) { value }

The compiler, however, will complain if the match is not exhaustive (for example, there is more than one constructor).

Lists

⚠️ NOTE: this syntax is not yet available. Lists cons is currently using the :: infix syntax.

Although lists are defined as normal enums, a more handy syntax is available:

[]
// is sugar for:
Nil

[1, 2, 3]
// is sugar for:
Cons(1, Cons(2, Cons(3, Nil)))


[1, 2, ..tl]
// is sugar for:
Cons(1, Cons(2, tl))

The same syntax is available in pattern matching. For example, that's one way we could define a sum function:

pub let sum = fn lst {
  match lst {
    [] => 0,
    [hd, ..tl] => 1 + sum(lst),
  }
}

Type aliases

⚠️ NOTE: type aliases aren't implemented yet

You can implement nominal type alises using the type keyword:

type IntList = List<Int>

Type aliases can have type parameters as well:

type MyResult<a> = Result<a, MyError>

Traits

Traits are type constraints that define common behaviour.

A trait definition consists in a series of values declarations, where exactly one type variable is bound to a trait constraint:

trait Show {
  @type (a) -> String where a: Show
  let show
}

⚠️ NOTE: it's not possible to create user-defined traits yet. Currently built-in traits are: Show, Eq, Cmp and Ord.

Named types can then define their specialized implementation of each trait:

impl Show for List<a> where a: Show {
  let show = fn lst {
    // .. implementation
  }
}

You can then use the function defined in a trait with the values that implement it:

// the following is valid, because Int implements Show
show(42)

// the following is not, because `() -> Int` doesn't
show(fn { 0 })

Traits constraints are inferred automatically:

// inferred as: `(a) -> String where a: Show`
let my_fn = fn a {
  show(a)
}

but can also be specified manually with type hints:

@type (a) -> String where a: Show
let my_fn = // ..

Ambiguous check

Unlike interfaces of object oriented languages, traits are resolved during typechecking, not at runtime. You can see traits as implicitly passed arguments, resolved statically. As a consequence, in order not to have an ill-typed program, each trait boundary must appear in the top-level type, whether that's automatically inferred, or manually specified. For example, it's not possible to write the following:

// doesn't compile!
let ill_typed_fn = fn x {
  let _ = fn a { show(a) };
  x + 1
}

On the other hand, this means it's possible to write things that wouldn't be possible with interfaces based on runtime informations:

@type (String) -> Maybe<a> where a: FromJson
let parse = // ..

@type Maybe<List<Int>>
let my_value = parse("[1, 2, 3]")

Deriving

Some traits can be derived automatically by the compiler:

@deriving(Eq, Show, Cmp)
pub(..) type Some<a> {
  Some(a),
  None,
}

This will automatically create a trait implementation of the given traits

⚠️ NOTE: it's not possible to create user-defined traits implementations yet. You can only automatically derive (without the @derive annotation)

Modules

Creating a file implicitly creates a module whose the namespace is the file's path.

You can import values and types of different modules with import statements, that must appear first in the module.

import MyModule
// Now you can use the MyModule.some_value syntax to access to
// `some_value` defined in MyModule
// You can also access types and constructors

Imports can be unqualified too:

import MyModule.{some_value, MyType(..)}
// this adds to the scope:
// the `some_value` value
// the `MyType` type
// `MyType` constructors (because of the `(..)`)

You can import nested modules using the import My/Nested/Module syntax

Visibility

Both values and types are module-private by default. You can use the pub modifier to make them visible from other modules:

pub let my_value = // ..

pub type MyAlias = // ..

pub enum MyEnum {
  // ..
}

pub struct MyStruct {
  // ..
}

In the case of enums and structs, just using pub will not expose the underlying implementation (the fields or the costructors). You can expose that using the pub(..) visibility:

pub(..) enum MyEnum {
  // ..
}
pub(..) struct MyStruct {
  // ..
}

Pipe operator

The pipe (|>) operator is a syntax sugar to avoid nested function calls.

a |> f(x, y) is desugared as f(a, x, y) from the compiler

Pipe calls can be nested, e.g. f(g(arg, x1, x2), y1) is the same as:

arg
|> g(x1, x2)
|> f(y1)

let# syntax

let# is a powerful syntax sugar that allows to rewrite this:

Mod.some_function(value, fn x {
  body
})

As the following:

{
  let#Mod.some_function x = value;
  body
}

You can chain multiple let# expressions. This syntax is very useful in many situations, here are a couple of examples of idioms:

await-like syntax

The let# syntax is useful to avoid deeply nested calls to Task.await, that might become something similar to callback hell:

For example, the following expressions:

let program: Task<Unit> =
  Task.await(IO.println("Enter you name"), fn _ {
    Task.await(IO.readline, fn name {
      IO.println("Hello " ++ name ++ "!")
    })
  })

Can be rewritten as:

{
  let#Task.await _ = IO.println("Enter your name");
  let#Task.await name = IO.readline;
  IO.println("Hello " ++ name ++ "!")
}

Errors early exit (similar to rust's ? sugar)

Since you don't have try-catch in Kestrel, errors have to be modelled as data, typically using the Result type. This results in lots of Result.and_then chained call.

You can simplify this using let#:

let get_some_number: (String) -> Result<Int, String>
let get_data_by_id: (Int) -> Result<MyData, String>

// Inferred as `Result<MyData, String>`
let get_data = {
  let#Result.and_then int = get_some_number("example");
  let#Result.and_then my_data = get_some_number(int);
  Ok(my_data)
}

This is similar to writing the following rust code:

fn get_data() {
  let int = get_some_number("example")?;
  let my_data = get_some_number(int)?;
  Ok(my_data)
}

Like in rust, you might want to combine this with Result.map_err in case the results have a different error type.

You can apply a similar pattern in effectful computations using Task.await_ok, or to the Option type using Option.and_then.

List comprehension

While, for simplicity's sake, Kestrel doesn't have an ad-hoc list comprehension syntax, you can use let# to archieve the same goal.

For example:

let comprehension = {
  let#List.flat_map x = [1, 2, 3];
  let#List.flat_map y = ['a', 'b'];
  let#List.guard _unit = x <= 2;
  [(x, y)]
}

Will yield [(1, 'a'), (1, 'b'), (2, 'a'), (2, 'b')]

Create a project

Kestrel's cli is the unified tool to run, typecheck, build, format your kestrel projects, and more.

You can install the it via npm:

npm install -g kestrel-lang

A vscode extension is also available.

Once you installed the cli, you can create a new project using

kestrel init

Hello world

We can use the the IO.println function to display some text. But just calling the function won't really "do" anything. To actually execute side effects, we'll need to assign them to a publically exposed value called main, of type Task<Unit>:

import IO

pub let main = IO.println("Hello world!")