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:
- Arithmetic:
+,-,*,/,% - Comparison:
==,eq,!=,ne,lt,le,gt,ge - Logical:
not,and,or - Concatenation and set union:
++(for lists, strings, sets and maps)
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.
record.field-name→ extracts a field from a recordunion ! tag→ extracts a tagged variant from a union
Also note that deconstruction is a suffix operation, while construction is prefix:
field-name: valuetag ~ variant
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:
- the
okoperator, which will returnfalse - a
whenstatement or when operator?that accepts the pattern - a
letstatement, if its declared type includes the value- that is,
letwill not infer escaping values
- that is,
- the function itself, if it can return the value
- future:
catchblocks
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.
Int,StrandBoolare built-in types(field: Type, ...)for recordstag ~ Type | ...for unions\[T]is shorthand forList\[T]- There isn’t a shorthand for
Set\[T] \[K: V]is shorthand forMap\[K, V]
Value Types
In Tenet, a value is defined by some properties:
- has a clear structure and meaning
- has a well-defined notion of what it means to be equal
- can be fully encoded and decoded
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.
Int— unbounded signed integers.Str— a finite sequences of Unicode code points. No normalization is performed. A string declared mutable may be used like aStringBuilderin other languages.Bool— the usualtrueandfalsevalues
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 \[].
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:
- can you capture a mutable name?
- what type does a function value have?
- what does equality mean?
- can you serialize them?
- can you break out of
forEachfrom within a lambda?
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.
- The empty list
[]has typeList[⊥]and is a member of everyList[T]. - The empty set
set\[]has typeSet[⊥]and is a subset of everySet[T]. - The empty map
[:]has type[⊥: ⊥]and is a member of every[K: V].
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.