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.tlThe 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:
- the function is named
next; - it accepts
light, aTrafficLight; - it returns another
TrafficLight; and - its result depends on which constructor was supplied.
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 -> IntIt 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 + 1Putting 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 } UnitThe 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:
- an algebraic data type organizes a known family of cases;
- a class provides nominal identity and dynamic method dispatch; and
- a structure or algebra provides coherent evidence that a type supports an abstraction.
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.
- Change
next(Red)by altering theRedbranch, then predict the output. - Add
FlashingtoTrafficLightand observe the coverage diagnostics before adding branches to both functions. - Replace the incrementing lambda with
fn(x:Int) : Int = x * 2. - Call
renderwith aStringand let the compiler selectShow(String). - Pass a function value to
renderand see whether suitableShowevidence 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:
- modules and declarations;
- algebraic data and constructors;
- exhaustive pattern matching;
- pure named functions;
- anonymous and higher-order functions;
- implicit type parameters;
- algebra evidence through
requires; - effect rows and console operations;
- action sequencing; and
- the boundary between portable language semantics and the current native provider.
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.