The Tenet Language

Tenet is an early-binding but dynamic feeling language that aims to be easily embeddable to provide safe and consistent scripting.

Technical ideas

We’ll start with some concrete ideas in Tenet, and move on to some intangibles.

Type system

Values in Tenet are unified (no primitive / reference distinction) and have a well-defined equality. Notably, Tenet doesn’t have function values.

Types are algebraic and structural. Tenet’s union type is tagged unions, and the product type is records, along with atoms (strings, integers) and containers.

Mutability respects referential integrity. Any variable can be mutable, and references to mutable names can be shared safely. Operations like iteration over lists are guaranteed to be safe.

Control flow

Structured programming and concurrency guarantee that when a function ends, any references it borrowed are released.

Powerful control flow as control can jump out of block arguments or inner functions, and anything, including expressions, can have a label. Escaping (similar to throwing) is cheap and can be done within expressions.

Pattern matching is pervasive and recursive, and tagged unions are cheap.

Object orientation

Stators, coming soon, are the “guts” of an object, implemented as a hierarchical finite state machine, and can be stack allocated. Objects are stators that live on the heap and are managed by an object capability.

Capabilities, coming soon, are a kind of pseudo-value that help manage lifetimes and permissions. Capabilities are needed to allocate objects, perform IO, etc., and may themselves be managed by objects or passed as special arguments.

Design Rationale

The itch I was initially wanted to scratch was frustration at various anemic type systems. Working with languages like Haskell, they are a real joy and it’s fun bending your mind to work within the functional paradigm. That is until, one day,you realize that they solved the problem of mutation by forcing the user to implement it.

The problem of how to share values is fairly ubiquitous, but it points towards a deeper issue: how we establish a boundary between independent code units, and how that evolves over time. The solution that seems likely is we need to be able to translate Tenet to other languages, so that the other side of the boundary is fully semantically consistent.

I’d like Tenet to have a core feature set that’s fun and satisfyig to code in. For instance, the syntax tries to be very flexible, our guiding idea is that the programmer should be able to write what he means rather than trying to force everything into one true concept.

By the same token, many of the semantics are very clearly defined, so a string is just a string, a number is just a number and the aims is that the basics of Tenet will feel solid and reliable.

Who Should Use Tenet?

At this point, people curious about a brand new language. Speculating as to how Tenet’s development will unfold.

An early target will be Tenet’s own development tools, and it should be pretty good at writing CLIs and simple web applications.

Before long, there will be some good applications for embedding. The language should be pretty quick in an interpreter, and it’s got a very straightforward and correct value system that can cross a language boundary.

This will make it ideal for embedding in an application, or even in a database. To dogfood this, I’d like to use Tenet to implement some of the support utilities.

Key Features

Getting Started

To dig into some more details:

Clear Data Modeling

Tenet has a structural typing system. That is, any record type (x: Int, y: Int) is the same type regardless of its name, but (x: Int, y: Int, z: Int) has no relation to the first. That said, #b | #c is a subtype of #a | #b | #c.

type Point = (x: Int, y: Int)

type Result(T) = ok ~ T | #err

type User = (
    id: Int,
    name: Str,
    email: address ~ Str | #none
)

Pervasive Pattern Matching

The values work with well-defined pattern matching that can take a number of forms: a when statement, a ? operator, and even simple pattern matching on assignment.

when get-result() {
    ok ~ (x: let x, y: let y) -> print-line("x: \(x) y: \(y)");
    #err -> print-line("error!");
}
let email-or-blank = get-user().email ? { 
    address ~ let addr -> addr;
    #none -> "";
}
ok ~ (x: let x, y: let y) = get-point();

Escaping Instead of Exceptions

Pattern matching and values are the basis of Tenet’s escaping mechanism. The basic idea is that certain commands cause a value to “escape” normal computation until it’s caught.

That email-or-blank example was a bit verbose, let’s simplify it:

let email-or-blank = get-user().email ! address ?? "";

// Example evaluation:
// (id: 5, name: "Bob", email: address~"bob@bob.com").email ! address ?? "";
// address~"bob@bob.com" ! address ?? "";
// "bob@bob.com" ?? "";
// "bob@bob.com"

// (id: 6, name: "Fred", email: #none).email ! address ?? "";
// #none ! address ?? "";
// (esc #none) ?? "";
// "";

That’s much simpler: either the email address is an address, which is known to be a string, or the computation escapes to ?? which replaces it with a space.

This is very similar to the obj.?field.?field ?? default, except we don’t have to rely on the type author picking convenient null values, we can pick any path through unions.

Likewise, the author of the values doesn’t have to decide that some results are “Exception” or “Error,” rather, the caller places the expected computation in the happy path, and can then catch unexpected data in a catch block.

fun load-user() => ok ~ User | #not-found | #permission-denied {
 ... 
}

do {
    let user = load-user() ! ok
    process(user)
} catch #not-found -> {
    show-error("User not found")
} catch #permission-denied -> {
    redirect-to-login()
}

while ok let line = read-line() ! line {
    print-line(line);
}

The ! ok asserts the happy path. If the result is not ok ~ ..., the result escapes and can be caught with catch.

In the second case, the ok operator returns true if its argument evaluated without escaping, and false if it escapes. Another operator that catches escapes this way is ?? which simply replaces an escaping value with a default.

fun percent(num: Int) => #rat ~ Int | #div-by-zero { 
    return num ? { 0 -> #div-by-zero; _ -> rat ~ 100 / num; }
}

for num in list-of-numbers() {
    print-line("\(percent(num: ^) ! rat)%")
} catch #div-by-zero {
    print-line("NaN")
    continue
}

Block Arguments

Rather than first-class functions, Tenet has block arguments.

fun transform(item: Item) => good ~ Item | #failed {
    ...
}

let mut result = []
for batch in fetch-batches() {
    result ++= process-items(batch) { item =>
        reply transform(item) ! good
    }
} catch #failed -> {
    break
}

Here, we see that an escaped #failed in the block can escape to a catch block on the loop, which can then break or continue the loop.

Thus, block arguments are more limited than function values because you can’t assign them to variables. However, this allows us to use control flow within blocks to exit containing loops or functions entirely.

And functions can never truly be first-class: Rice’s theorem holds that finding a sane definition of equality for functions would be equivalent to solving the halting problem.

Collections

At the present, Tenet supports the reliable lists, sets and maps.

Tenet’s semantics give us some guarantees:

let flavors = [#apple, #banana, #kiwi]
let colors = set\[#red, #green, #blue]
let scores = ["alice": 95, "bob": 87]

We are working on a more general collections methodology. There should be some compiler support to allow describing a collection and its methods in terms of the relational algebra, making it reasonable to construct something like a map with list values or a bidirectional map, as well as putting constraints on a collection.