Authoritative source: A guided tour of the language

A guided tour of the language

This chapter combines several tulam ideas in one runnable program. It is a tour, not a test: each feature receives a dedicated chapter later.

Create tour.tl with the following contents:

module Tour.Main;

type TrafficLight = Red + Amber + Green;

function next(light:TrafficLight) : TrafficLight = match light
    | Red -> Green
    | Amber -> Red
    | Green -> Amber;

function waitSeconds(light:TrafficLight) : Int = match light
    | Red -> 30
    | Amber -> 4
    | Green -> 25;

function applyTwice(f:Int -> Int, item:Int) : Int =
    f(f(item));

function render[a:Type](item:a) : String
    requires Show(a) = show(item);

function report(label:String, item:Int)
    : Eff { console:Console } Unit = action {
    putStrLn(concat(label, render(item)))
};

action main() : Eff { console:Console } Unit = {
    current = Red;
    following = next(current);
    adjusted = applyTwice(fn(x:Int) : Int = x + 1,
                          waitSeconds(following));
    report("current wait: ", waitSeconds(current));
    report("next wait plus two: ", adjusted)
};

Run it from the compiler checkout:

stack exec tulam -- run tour.tl

The program prints:

current wait: 30
next wait plus two: 27

We will now walk through how the program says what it means.

A module gives the file a stable name

module Tour.Main;

A module name provides a stable identity independent of the current filename. Inside a project source root, the path would be Tour/Main.tl. Single-file compilation also accepts the explicit header.

Modules control imports, exports, and visibility. The compiler and project planner use them to construct a checked dependency graph rather than relying on textual inclusion.

A data type lists the possible cases

type TrafficLight = Red + Amber + Green;

TrafficLight is a new algebraic data type with exactly three constructors: Red, Amber, and Green.

The type and constructor names begin with capital letters. The + signs in a data declaration separate alternatives. A TrafficLight value is one of those alternatives, never an arbitrary integer or string standing in for one.

Constructors can also carry fields:

type Message =
    Text * body:String
  + Move * x:Int * y:Int;

Here * introduces the fields belonging to a constructor. We do not need payloads for the traffic-light example.

Pattern matching handles every case

function next(light:TrafficLight) : TrafficLight = match light
    | Red -> Green
    | Amber -> Red
    | Green -> Amber;

The declaration says:

Each | pattern -> expression branch covers one case. The same structure defines the wait time:

function waitSeconds(light:TrafficLight) : Int = match light
    | Red -> 30
    | Amber -> 4
    | Green -> 25;

The checker verifies coverage. If you remove the Amber branch, this closed three-constructor type is no longer handled exhaustively and the program is rejected. If branches produce incompatible result types, that is also a static error.

Pattern matching works on much more than enumerations: constructor fields, tuples, records, literals, nested data, GADT refinements, and sealed class hierarchies all participate in the language’s matching model.

Functions can receive functions

function applyTwice(f:Int -> Int, item:Int) : Int =
    f(f(item));

The parameter f has type:

Int -> Int

It is a function from Int to Int. applyTwice calls it once, then passes that result back to the same function.

The caller supplies an anonymous function:

fn(x:Int) : Int = x + 1

Putting the two together:

applyTwice(fn(x:Int) : Int = x + 1, 25)

produces 27. Functions are values: they can be passed, returned, stored in data, and close over values from their surrounding lexical scope.

Named functions and fn expressions share the same parameter, result, and application model. Higher-order programming is therefore ordinary programming, not a separate subsystem.

Type parameters make code generic

The pure render function can turn any showable type into a string:

function render[a:Type](item:a) : String
    requires Show(a) = show(item);

Square brackets introduce an implicit parameter:

[a:Type]

a stands for a type selected from the call. If the caller passes an Int, then a is Int; if it passes a String, a is String.

This alone would not justify calling show(item). Not every imaginable type necessarily knows how to turn itself into text. The constraint:

requires Show(a)

asks the compiler for coherent Show evidence for the chosen type. The standard library supplies that evidence for Int, so the call from report checks.

This is similar in purpose to a typeclass constraint, but tulam treats the selected evidence as an explicit part of typed elaboration. Structures, algebras, morphisms, instances, named alternatives, and laws build on this foundation.

The caller still writes an ordinary call:

render(30)

The type argument and evidence are inferred when the choice is unambiguous. The separate report function combines this pure generic abstraction with a concrete console operation:

function report(label:String, item:Int)
    : Eff { console:Console } Unit = action {
    putStrLn(concat(label, render(item)))
};

Effects stay in the type

report is not pure because it writes to the console:

: Eff { console:Console } Unit

The operation appears in an action block:

action {
    putStrLn(concat(label, render(item)))
}

main has the same console requirement because it calls report:

action main() : Eff { console:Console } Unit = { ... };

Effects describe capabilities a computation may require. They can be inferred, polymorphic, qualified, transformed, and handled. A handler can reinterpret an effect—for example, capturing console output during a test—without changing the pure functions that produce the values.

The native environment supplies the default Console handler used here.

Action blocks make sequencing visible

The body of main mixes pure local bindings and effectful statements:

action main() : Eff { console:Console } Unit = {
    current = Red;
    following = next(current);
    adjusted = applyTwice(fn(x:Int) : Int = x + 1,
                          waitSeconds(following));
    report("current wait: ", waitSeconds(current));
    report("next wait plus two: ", adjusted)
};

Bindings use name = pureExpression. Later statements can see earlier names. Effectful expressions are sequenced in source order. The final expression supplies the action result.

For an operation that produces a value inside the effect, action syntax uses <-:

action ask() : Eff { console:Console } String = {
    putStrLn("What is your name?");
    name <- readLine();
    name
};

This distinction makes data flow visible without pretending that an effectful request is an ordinary pure value.

Where dependent types enter

Nothing in the traffic-light program requires dependency. That is intentional: ordinary programs should not acquire advanced notation without a reason.

Suppose, however, that a controller stores a schedule together with its number of entries. An ordinary pair can accidentally disagree. A dependent product can connect the fields:

type Schedule(a:Type) =
    count:Nat * entries:Vec(a, count);

The type of entries refers to the preceding count. Construction must make the two agree. A function can preserve that fact in its result:

function repeat[a:Type](count:Nat, item:a) : Vec(a, count) = ...;

These are not runtime assertions disguised as types. They are checked relationships that can guide elaboration, pattern refinement, and safe API design. Chapters 15 and 16 develop dependent functions and products carefully.

Objects are available without replacing data

Algebraic data and pattern matching are excellent when the set of cases is central and known. tulam also has first-class nominal classes, single inheritance, dynamic dispatch, abstract and sealed classes, and checked subtyping.

That is not merely syntax sugar for records, and it does not replace structures or algebras. The systems answer different questions:

The guide introduces them separately so that each is chosen for its meaning, not because one mechanism must imitate all the others.

The target model is behind ordinary calls

The tour program contains no “run this call on LLVM” annotation. Its calls are ordinary calls.

The current compiler checks the program into a backend-neutral RuntimeCore artifact and selects the LLVM native provider. In the longer-term model, a declaration may state a placement policy once and target blocks may provide conforming implementations for native, .NET, JavaScript, accelerators, or other environments. Application callers keep the same semantic call.

This matters for interoperability and performance. A target implementation may map an operation to a host API or specialized instruction, but it must refine the same canonical declaration. Representation conversion and cross-target transfer remain explicit, typed concepts.

Current status: LLVM native is the only execution backend. General target planning, .NET and JavaScript providers, transfer routes, and fallback are specified directions with incomplete implementation. This program itself runs entirely through the native reference path.

Try a few changes

Small experiments help make the tour concrete.

  1. Change next(Red) by altering the Red branch, then predict the output.
  2. Add Flashing to TrafficLight and observe the coverage diagnostics before adding branches to both functions.
  3. Replace the incrementing lambda with fn(x:Int) : Int = x * 2.
  4. Call render with a String and let the compiler select Show(String).
  5. Pass a function value to render and see whether suitable Show evidence exists.

The fifth experiment is especially useful: generic syntax does not mean every operation works for every type. Constraints state the exact additional capability required.

What the tour has shown

In one small program you have seen:

The next part of the guide slows down and treats everyday expressions and data one subject at a time. Continue with Chapter 6 after it is drafted, or return to the complete table of contents.