Tenet reference

Expressions

Tenet expressions are mostly familiar but with a few distinctive features, particularly around deconstruction and escaping.

Basic Operators

Tenet supports the usual arithmetic, comparison, and logical operators:

Tenet has floor division, and similar semantics for modulo, instead of truncating.

Deconstruction Operations

Tenet provides two analogous deconstruction operations to extract parts of composite values.

Also note that deconstruction is a suffix operation, while construction is prefix:

Record Field Access .

The dot operator . extracts a field from a record. There’s no run-time computed field access.

do {
    let p = (x: 3, y: 4);
    show("p is ", p);
    show("p.x is ", p.x);
    show("p.y is ", p.y);
}

Union Variant Unpacking !

The unpacking operator ! unpacks a tagged value from a union. There’s no run-time computed variant unpacking.

If the value has the expected tag, the variant is returned. If the tag doesn’t match, the entire value escapes.

do {
    let result: ok ~ Str | #miss = ok ~ "apple";
    let fruit = result ! ok;
    print-line("Fruit is " ++ fruit);
}

Expression-level Pattern Matching ? and ?!

The when operators ? and ?! perform pattern matching inside a larger expression without introducing a full when block. They have very similar syntax to when, except that, being a statement, when can do nothing and move on to the next statement.

The ? operator performs pattern matching and is always exhaustive — all cases must be covered, and all branches must produce a value with a common supertype.

do {
    show("For the result ", let mut r = ok ~ "widget");
    print-line("The description is " ++ describe-result(r));
    show("For the result ", r = #miss);
    print-line("The description is " ++ describe-result(r));
    show("For the result ", r = error ~ "Invalid State");
    print-line("The description is " ++ describe-result(r));
}
fun describe-result(result: ok ~ Str | #miss | error ~ Str) => Str {
    return result ? {
        ok ~ let v    -> "success: " ++ v;
        #miss         -> "not found";
        error ~ let e -> "failed with " ++ e;
    };
}

The escaping when operator ?! is similar but does not have to be exhaustive: any unmatched cases are allowed to escape.

fun escaping-when(arg: #to-the-moon | ok~Int | #miss)
    => num ~ Int | #to-the-moon
{
    let value = arg ?! {
        ok ~ let v -> v;
        #miss  -> 0;
        // other tags are allowed to escape
    };
    return num ~ (value * 10);
}
do {
    show("ok~5: ", escaping-when(ok~5));
    show("#miss: ", escaping-when(#miss));
    show("#to-the-moon: ", escaping-when(#to-the-moon));
}

Escaping via the unpack operator !

The primary mechanism for signaling and handling abnormal results is called escaping.

When a tagged value is unpacked using the ! operator with a specific tag, if the actual tag does not match, the value escapes outward until it is caught by a surrounding context or reaches a function boundary.

do {
  try(#nope);
  try(ok ~ "55");
  try(ok ~ "ten");
}
fun escaping-value(arg: ok ~ Str | #nope): ok ~ Int | #nope | #fail {
    let add-a-zero = arg ! ok ++ "0";
    let to-num = str-to-int(add-a-zero) ! num;
    return ok ~ to-num;
}

fun try(arg: ok ~ Str | #nope): Unit {
    show("arg = ", arg);
    when escaping-value(arg) {
        ok ~ let x -> print-line("ok : " ++ int-to-str(x));
        #nope      -> print-line("nope :-(");
        #fail      -> print-line("wasn't a number :-(");
    }
}

If an escaped value is not handled before exiting its containing function, it is a compile-time error.

This design intentionally leaves the question of “what is an error” up to the caller. If we look up a key in a dictionary, returning #miss is the intuitive error.

But if we’re loading that dictionary and expect our data to contain no duplicates, found ~ prior-value would be the error.

When operator ?

We looked at the when statement as a way to handle different results, but the ? operator, known as the when operator, operates very similarly:

fun try(arg: ok ~ Int | #nope | #fail): Unit {
    print-line(arg ? {
        ok ~ let x -> "ok : " ++ int-to-str(x);
        #nope      -> "nope :-(";
        #fail      -> "wasn't a number :-(";
    });
}
do {
  try(#nope);
  try(ok ~ 550);
  try(#fail);
}

The main difference in the semantics is the ? operator must return a value, while the when statement can perform actions.

Catching Escapes

So what the unpacking operator ! does is assert we got the desired value. If it’s not the desired kind of value, the value escapes control. For convenience, esc foo ~ "bar" behaves like (foo ~ bar) ! no-such-tag-exists.

Escaping values can be caught by:

Function calls

Functions are invoked with named arguments, but the early arguments can be passed positionally in the same order as in the function definition:

fun process(data: Str, mode: #strict | #loose) {
    show("data: ", data);
    show("mode: ", mode);
}
do {
  print-line("process(data: \"input\", mode: #strict)");
  process(data: "input", mode: #strict);
  print-line("process(\"input\", mode: #strict)");
  process("input", mode: #strict);
  print-line("process(\"input\", #strict)");
  process("input", #strict);
}

Statements in Tenet

Like more traditional languages, Tenet makes a distinction between statements and expressions. Statements start with a keyword and have some number of clauses within them.

Expression statements

Having said all that, we’ll start by showing where the rule is blurred, as expressions can be statements. An expression statement isn’t introduced with a keyword, it’s just an expression.

do {
  5;  // Legal, but doesn't do anything.
  print-line("Invoking a builtin function is a bit more useful.");
}

That’s all fairly standard.

Let Bindings and Assignments

The let statement is used to declare new variable names. Let’s look at it working as a traditional statement:

// At the top level, `let` creates constant names:
let constant-name = "constant-value";

do {
    // A locally visible name can be defined,
    // its scope is restricted to the nearest braces.
    let local-name = "local value";
    print-line("local-name = " ++ local-name);
    // The mut modifier indicates the value may change
    let mut mutable-name = "first value";
    print-line("mutable-name = " ++ mutable-name);
    // We don't use let to update it:
    mutable-name = "second value";
    print-line("mutable-name = " ++ mutable-name);
}

We saw the mut modifier to let. By default, a name is immutable, and in Tenet this means it is fully immutable. If we declare it with a mut modifier, it’s fully mutable.

Tenet allows assignment expressions. While these can make code hard to read, sometimes they’re the right way to do it.

do {
    // A mutable name is declared with an initial value
    let mut name = "up";
    // assignments can happen in expressions
    print-line("name = " ++ name ++ ", now = " ++ (name = "down"));
    print-line("name = " ++ name);
}

Names can be defined with let within an expression, because these are technically expressions. The value of the expression is the value stored in the name, same as with an assignment.

do {
    print-line("new-name = " ++ (let new-name = "value"));
    print-line("it's still " ++ new-name);
}

Here, new-name is visible within the nearest curly braces. Later, we’ll also see how let can be used in when statements.

Compound assignment

Related to let, but never used with let, are compound assignment operators.

All the arithmetic operators have compound assignment, but ++= is particularly useful, and and= and or= are short-circuiting.

These operators follow the pattern that x = x ⊙ y can be abbreviated as x ⊙= y. So += does what you’d expect:

do {
    let mut x = 3;
    show("x = ", x);
    x += 5;
    show("x = ", x);
}

The concatenation compound assignment ++= appends strings and extends lists:

do {
    let mut str = "";
    let mut list: \[Int] = \[];
    let mut i = 1;
    while i lt 10 {
        list ++= \[i];
        if i gt 1 { str ++= ", "; }
        str ++= int-to-str(i);
        i += 1;
    }
    show("str: ", str);
    show("list: ", list);
}

The interpreter presently implements mutable strings, lists, sets and maps with a buffered container, so ++= can be used within a loop without quadratic complexity.

And when the normal operators short-circuit, so do their compounds:

fun slow-test(car: Str) => Bool {
    if car == "ford" {
        print-line("The Ford broke down.");
        return false;
    } else {
        print-line("Your " ++ car ++ " is fine.");
        return true;
    }
}
let input-list = \["honda", "chrysler", "ford", "pontiac", "bmw"];

do {
    let mut all-passed = true;
    for item in input-list {
        all-passed and= slow-test(item);
    }
    show("all-passed = ", all-passed);
}

Function Definitions

A function in Tenet is typically defined as:

fun repeat(text: Str, count: Int) => Str {
    let mut i = 0;
    let mut out = "";
    while i lt count {
        out ++= text;
        i += 1;
    }
    return out;
}
do {
    let mut i = 0;
    while i lt 10 {
        print-line(repeat("*", i));
        i += 1;
    }
}

Early arguments may be passed positionally when calling the function. Later arguments must be named.

do {
    print-line(repeat("wow", count: 3));
    print-line(repeat("ha", 2));
    print-line(txt: repeat(count: 4, text:"nope"));
}

Escaping functions

Every function must handle (or explicitly allow to escape) all possible returned values from its body. That is, suppose we have a function where #miss escapes:

type Foo = (a: Int, b: Int);
fun work-on-data(might-miss: ok ~ Foo | #miss) => good ~ Int | #miss {
    let value = might-miss ! ok;
    return good ~ (value.a + value.b);
}
do {
    show("ok: ", work-on-data(ok ~ (a: 3, b: 4)));
    show("miss: ", work-on-data(#miss));
}

Since the function itself is allowed to return #miss, the escaping value is returned normally.

If a value can escape out of a function without being caught, and the return type doesn’t explicitly include that variant, it is a compile-time error. In particular, if a function return type isn’t a union, it doesn’t allow escaping values.

Control Flow

If / Else

Tenet if/else chains are fairly typical.

fun categorize(size: Int) => #negative | #lt10 | #gte10 {
    if size lt 0 {
        return #negative;
    } else if size lt 10 {
        return #lt10;
    } else {
        return #gte10;
    }
}
do {
    show("-3: ", categorize(-3));
    show("7: ", categorize(7));
    show("22: ", categorize(22));
}

When Statement

The when statement is the primary way to deconstruct unions and records via pattern matching.

fun process(union: red ~ Int | #yellow | green ~ (x: Str, y: Int)) {
    when union {
        red ~ let value -> show("red: ", value);
        #yellow -> print-line("yellow");
        green ~ (x: let x, y: let y) -> {
            show("green.x: ", x);
            show("green.y: ", y);
        }
    }
}
do {
    process(union: red~5);
    process(union: green~(x: "apple", y: 5));
}

The let value acts as a capture. It defines a name value that can be used within the corresponding action.

Let’s look at another way to capture: assigning a wildcard. The wildcard pattern _ matches anything. We can use it if we’re not interested in the pattern, but also assign it to a mutable name.

Here we use let x-val because we only need the x values in the action, but we assign y-val to the wilcard to save the y values for later.

fun all-red() { print-line("red"); }
fun one-green() { show("green: ", x); }
fun two-green(x: Int, y: Int) { show("gren: ", (x: ^, y: ^)); }
let record-expression = (x: #red, y: green ~ 32);

do {
    let mut y-val: Int = 0;
    when record-expression {
        (x: #red, y: #red) -> all-red();
        (x: green ~ let x-val, y: #red) -> one-green(x: x-val);
        (x: #red, y: green ~ y-val = _) -> one-green(x: y-val);
        (x: green ~ let x-val, y: green ~ y-val = _) ->
            two-green(x: x-val, y: y-val);
    }
}

As a convenience, if only one action is required, when expr pattern -> action is allowed.

Return and reply

Any function that declares a return type must return an appropriate value.

fun regular-function() => Str {
    return "okay";
}

A reply statement is a kind of inner return. This is useful for the ? operator, since each action must reply with a value.

do {
    let subject = #ok;
    let answer = subject ? {
        #simple -> "simple"; // An expression doesn't require `reply`
        #ok -> {
            print-line("got #ok");
            reply "okay";    // A block does require reply.
        }
        #unexpected -> never; // Some simple statements don't require a block.
    };
    show("answer: ", answer);
}

Do Statements

The do statement introduces a new block. It’s required at the top-level of a Tenet program.

do {
    let value = time-millis();
    print-line(int-to-str(value));
}

A do block can also be embedded in an expression, and passes a value with reply:

let complicated = do {
    let number = 5; // Not visible outside!
    reply number * 2;
};

Esc

In some cases, it may be desirable to unconditionally escape at a given point. The esc statement does is equivalent to deliberately unpcking with the wrong tag, e.g. #x ! y.

esc #foo  // escapes with the #foo value specified

when subj {
  foo ~ let x -> handle-x(x);
  bar ~ _ -> esc; // implicitly escapes with subj
}

Never

If you determine control flow should never reach a point, mark it with a never statement. Our guidance is to use never freely in an application, and rarely in a library.

do {
    let mut i = 0;
    while i lt 10 {
        if i % 3 == 0 {
            i += 1;
            continue;
        }
        when i {
            6 -> never;
            _ -> print-line("i = " ++ int-to-str(i));
        }
        i += 1;
    }
}

Here, dropping every multiple of 3 should have eliminated 6, so we know that control should never reach that branch of the when statement.

The never statement asserts that control should “never” reach a specific point, and is the correct thing to do when reaching that point is a legitimate bug and can’t be proven so to the compiler.

Loops

Loops largely work as in other languages, with additional control through break and continue.

While Loops

while condition {
    ...
    continue // go back to reevaluate the condition
    ...
    break    // abort early
    ...
}

For Loops

A for loop evaluates a collection of some kind. Generally, we can’t guarantee the ordering of sets and maps.

do {
    for item in \["apple", "banana", "watermelon"] {
        print-line("Found " ++ item);
    }

    for item in set\["apple", "apple", "apple"] {
        print-line("Found " ++ item);
    }

    for letter in "Hello" {
        print-line(letter);
    }
}

Iteration is over List, Set, or Str. The iterator variable is scoped to the loop body.

Types in Tenet

Type Expressions

Type expressions appear in type definitions, function signatures, and annotations. In general, Tenet tries to make types homoiconic, that is, types usually look like the values they represent.

Value Types

In Tenet, a value is defined by some properties:

The practical implication of Tenet’s values is that any value can be placed in any variable, or stored within a record or collection. They can always be consistently serialized and deserialized.

Atomic Types

The current types are fairly basic; we’ll add Float and put some bounds on Int.

Algebraic Types

Records

A record is a fixed collection of named fields with potentially different types. Record types are closed — two records are compatible only if they have exactly the same field names and compatible field types.

type Point = (x: Int, y: Int);
let p = (x: 3, y: 4);
Tagged Unions

A union is a discriminated sum type. Every member is a tagged value tag ~ variant and an expression #tag is shorthand for tag ~ (), meaning the variant is the unit record value.

type Result =
    ok ~ Str
  | #err;

type Optional = just ~ Int | #nothing;

Unions are order-independent within Tenet semantics.

Container Types

Lists have a type \[T], or List\[T] in full. They are a homogeneous finite sequence of values of type T. A list value literal is written as \[a, b, c, d], and the empty list is \[].

**Sets** have a type `Set\[T]`. They are a homogeneous finite set of values of type `T`. A set value literal is written as `set\[a, b, c, d]`, and the empty set is `set\[]`.

Maps have a type \[K: V], or Map\[K: V] in full. They are a finite mapping from keys of type K to values of type V. A map literal is written as \[a: b, c: d], and the empty map is \[:].

type Color = #red | #blue;
let numbers : \[Int]     = \[1, 2, 3];
let colors : Set\[Color] = set\[#red, #blue];
let scores : \[Str: Int] = \["alice": 95, "bob": 87];

What’s missing

Some future features (objects, capabilities, stators, blocks) are missing, but we’re resolved to discuss them in a roadmap.

There’s no null

Tenet has no null or undefined special values. It’s not that we don’t like null, most languages have worked out how to tame it by making it plainly apparent.

The problem with null is it doesn’t go far enough: there’s only one null and we don’t know what it means. In Tenet, the user can plainly state that data is #missing, #corrupted, #blank, #lost, #blithering or whatever meaningful label applies.

Function types

Again, function types and functions as values are great. They raise some thorny questions:

We hold that a function fundamentally isn’t a value. Tenet will offer blocks that can be passed to functions and act as though they are inlined, and functors that are callable objects.

Special Types

These aren’t missing, but they’re not immediately user accessible.

Bottom ()

The empty type, used internally during type inference.

Top ()

This is used internally. In inference, we often need to find a common supertype of multiple types, and if this isn’t available, Top is a marker to indicate this.