An introduction to Tenet

Tenet is a language for expressing the data that is exchanged between systems that may be written by different people at different times.

We’re going to go through some of the basics of what Tenet provides to facilitate this.


Types aren’t normally the first thing introduced in a guide, but they’re more significant to our ultimate aim, which is to describe data being exchanged.


Our most basic types are Integer and String in this first version to keep things as simple as possible. We can define our own alias of them like so:

type Count := Integer
type Name := String

example_count := 5
example_name := "Tony"

We’re using the := operator to assign Integer to the type name Count. As this syntax may be a bit unfamiliar, this is also allowed:

type Count = Integer
type Name = String

example_count = 5
example_name = "Tony"

A few points:

We haven’t specified the binary capacity of either or the format. Tenet types are logical types and so integers behave like regular integers; addition won’t overflow or wrap around. A future version will add bounds checking, but there will never be a case where adding two positives will result in a value less than the original.

We don’t have to declare the types of example_count or example_name because the language ensures the type of a literal value is unambiguous.

Enumerated types

Enumerated types can be defined quite easily:

type Boolean := Union[true~~, false~~]

type Color := Union:

favorite_color := yellow~~
not_true := false~~

We’ll explain the double-tilde later when we discuss unions. Again, favorite_color is known to be a member of Color without stating so explicitly.


What a tuple lets you do is group diverse types of related information together, that is to say, a tuple type declares a fixed set of attributes with different types.

type Record := Tuple:
    number: Integer
    name: String
    address: String
    okay: Boolean
type Color_Record := Tuple[color: Color, index: Integer]

example_record := {number: 5, name: "Bob", address: "4121 Roanoke Rd", okay: true~~}
example_color := {color: red~~, index: 33}

This illustrates the two kinds of syntax: block form, and bracketed form. Both are entirely equivalent in meaning.1

And, again, the types of example_record and example_color are unambiguous.

You don’t have to separate words by underscores, but if words are separated by underscores, they can be translated to other conventions like camel-case unambiguously.

A few miscellaneous points:

  • A tuple type doesn’t have any ordering of its attributes.
  • We may provide a helper that identifies the ordering originally specified.
  • The names of attributes should not be quoted.
  • There are no optional attributes or defaults.

Lists, Sets and Maps

Container types represent aggregate values that comprise a variable number of elements that all have the same type.

type List_of_Integers := List[Integer]
type Set_of_Strings := Set[String]
type Map_of_Strings_to_Records := Map[String, Record]
type Map_of_Records_to_Strings := Map[Record, String]

example_list := [5, 3, 4, 9, -12]
example_set := set ["one", "two", "three"]
example_map := [
    "bob": {number: 5, name: "Bob", address: "4121 Roanoke Rd", okay: true~~},
    "fred": {number: 7, name: "Fred", address: "833 Little St", okay: false~~},
complex_keys := [
    {number: 5, name: "Bob", address: "4121 Roanoke Rd", okay: true~~}: "bob",
    {number: 7, name: "Fred", address: "833 Little St", okay: false~~}: "fred",
empty_map := [:]

Some small notes:

  • Lists may not have “missing” elements or be sparse or infinite.
  • Sets and maps may have arbitrarily complex keys.
  • But provided the types of the values are consistent.
  • Except you can’t use functions as keys.
  • Square brackets have two meanings.
  • Here, the construct list, set and map expressions.
  • Before, they were used to specify details of a type declaration.

There’s an important distinction here. You an absolutely do both of these things:

tuple_example := {name: "Fred", address: "1055 Nermish Rd"}
map_example := ["name": "Fred", "address": "1055 Nermish Rd"]

They are not the same, though, because map_example can only have String values, whereas tuple_example can have arbitrary attribute types. In addition, as we’ll see, the tuple_example is much easier to code for because it must have exactly the attributes and types you specify.

In addition, a map can have arbitrary expressions for keys, but the names of attributes of a tuple must2 be fixed. That is, this is invalid:

wont_compile := {(foo + bar): 33}

If a tuple you want to define should have an extension record, you can always add a Map attribute, and we’ll see how a Map can have values with varying types.


Tenet allows for discriminated unions. We’ve already seen enumerated types, so let’s expand on them:

type Return_Type := Union:
    ok ~ Record
    file_not_found ~ String
    access_denied ~~
    divide_by_zero ~~
    other_error ~~

type Optional_Name := Union:
    name ~ String
    missing ~~

example_error := access_denied~~
example_not_found := file_not_found ~ "/home/user/record.json"
list_of_names := [name ~ "Andy", name ~ "Jill", missing~~, name ~ "Fred"]

Unions are a great way to express succes or failure or anything in between. In this case, the Return_Type can inform a caller whether a function succeeded or not, and provide details as to why it failed.

What the double-tilde means

We mentioned the double-tiled under Enumerations, and as you can see, we can combine double-tilde entries with other entries. In a nutshell, the above definitions are simply shorthand for this:

type Return_Type := Union:
    ok ~ Record
    file_not_found ~ String
    access_denied ~ Tuple[]
    divide_by_zero ~ Tuple[]
    other_error ~ Tuple[]

type Optional_Name := Union:
    name ~ String
    missing ~ Tuple[]

example_error := access_denied ~ {}
example_not_found := file_not_found ~ "/home/user/record.json"
list_of_names := [name ~ "Andy", name ~ "Jill", missing ~ {}, name ~ "Fred"]

The empty tuple type Tuple[] is called Unit and has a single possible value, {}, so it’s a handy placeholder.

There is also an empty union type, Union[], which is the same idea as “void” in other languages, and it has no possible value.

To be clear: the language does not have any way for you to express a value for the empty union type because there is none. There is no “value that is not a value.”

That said, nothing is stopping you from using null~~ as a placeholder. It won’t have the special semantics of nulls in languages like SQL, of course.

Types Tenet does not have

Just to be clear, in the interests of a simple, reliable type system:

  • There is no value that is not a value; so no SQL NULL, null pointer, null reference or undefined value.
  • There is no three-valued boolean logic.3
  • There are no pointers or references.
  • There are no infinite or cyclic values.
  • There are no exception types.
  • There are no classes or inheritance.

All these ideas exist because people needed them, so let’s look at how we can get the functionality desired:

  • A null~~ value is just a regular value and can be part of any union type.
  • As true~~ and false~~ are ordinary enumerated values, you can write your own three-valued logic functions.
  • Unions provide a powerful way to return failure values to the consumer.

In the future:

  • Tenet will offer a “path” type to replace pointers and references.
  • Tenet will offer metaprogramming to create new types by combining or renaming tuple and union types.
  • Tenet may offer a facility to allow message passing through coroutines.


We can define a function like so:

def simple(first, second, third):
    return first * second + third

This syntax should be familiar to most Python programmers, and straightforward enough if you aren’t. Let’s add a type signature to it.

def simple(first: Integer, second: Integer, third: Integer) -> Integer:
    return first * second + third

x := simple(first: 1, second: 2, third: 3)

The name: Type syntax is similar to Python’s recent annotation syntax. As another Python-related note, the function call syntax uses colons, not equals signs, so both are the same. There are no default values, either.

A large point: argument names are generally mandatory. There is syntactic sugar for one and two argument functions, but the expectation is we’re generally writing functions that take many arguments and it’s easier on the reader if named arguments are always used. The order, of course, doesn’t matter.

Partial application

Although there are no default values, you can do what’s known as partial application meaning you specify some of the arguments to a function.

simpler := simple(first: 1, second: 2, ...)
x := simpler(third: 3)

Control statements

Tenet supports the standard control statements.

def control(list_of_things):
    fives := 0
    for item in list_of_things:
        if item.a == 5:
            fives += 1

    x := 2
    while x > 1 and x < 100:
        x *= fives

    return x

What it doesn’t support is any kind of print statement or other side-effect generating statement.

Tenet also supports a switch statement for advanced pattern matching.

def switch_example(complex_union):
    switch complex_union
    case one ~ {a: 5, b: "okay"}:
        return "okay - five!"
    case one ~ {a: 5, b: capture}:
        return capture
    case two ~ some_string:
        return some_string
        return "else!"

Here, the one ~ {a: 5, b: capture} ensures the variable has that specific structure, and then captures what’s in the b attribute and assigns it to capture.

Because all captures match as wildcards and are effectively assignments, this implies a caveat: patterns in cases can not be determined at runtime. Use an if statement for that.

def wont_work(left, right):
    switch left
    case right:
        return right
        return 10

def will_work(left, right):
    if left == right:
        return right
        return 10

Because the variable name matches as a wildcard and captures the matched value, wont_work(5, 15) would return 15 despite the fact that left doesn’t equal right, whereas will_work(5, 15) would return 10.

Destructuring assignments

There is some preliminary support for destructuring assignments.

{a, b, c} := {a: 1, b: 2, c: 3}
# Same as:
t := {a: 1, b: 2, c: 3}
a := t.a
b := t.b
c := t.c

# Renaming
{a -> alpha, beta <- b, c: gamma} := t
# Same as:
alpha := t.a
beta := t.b
gamma := t.c

Destructuring tagged expressions is best done through the switch statement.

Compound assignments are also supported, as are assignments within values. Array slices are not supported yet.

x := {a: [1, 2, 3]}
x.a[1] := 5
x := {a: [1, 5, 3]}


There is only preliminary work on modules. Import statements work and we can parse a bundle of files. Tenet’s syntax is aiming to have one simple way to import things.

import path.path.path (name, name as rename, name, name)
  • No relative imports, no import *, no “import qualified”, and a module directly corresponds to a single possible filename. The standard library is implied.
  • Imports will probably also be required to be at the top of the file; thus a reader can inspect the head of the file to determine its dependencies.
  • There will be no TENET_PATH variable.

All of this is up for debate and change, operating on the premise it’s easier to add than to take away.

Exports: At present everything is exported, and limiting exports will depend on how they need to be structured when organizing the project and building the final artifacts. In particular, the debate over whether tests should run against internal structures has never been resolved conclusively so I’m taking the conservative position that a language should assume there may be valid reasons to do so.

  1. A caveat: you can embed bracket form inside block form, but not block form inside bracket form. 

  2. The requirement that tuple attribute names be fixed may be lifted in the future via metaprogramming facilities. 

  3. Though you could write your own if you want.