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:
- Types — The type system
- Expressions — Operators and deconstruction (
!,?,?!) - Statements — Control flow, functions, and blocks
- Errors and Failures — Escaping and
never
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:
- any value can be used as a map key or set element
- you can’t modify a set element and make it disappear
- you can’t concurrently modify a collection to corrupt it or crash
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.