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:
Bool
List
, singly linked listsUnit
,Tuple2
, etc.Option
, to represent optional valuesResult
, to represent errors
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
andOrd
.
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 enum
s and struct
s, 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!")