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

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.

Atoms

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

type Count := Int
type Name := Str

let example_count := 5
let example_name := "Tony"

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

type Count = Int
type Name = Str

let example_count = 5
let example_name = "Tony"

A few points:

You need let before all assignments. It makes parsing reasonably sane, and because the language is delimited by keywords, we don’t need semicolons or any arcane semicolon insertion rules.

Type names must begin with a capital letter, and all other names must begin with a lowercase letter.

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 := #true | #false

type Color :=
    #red    |
    #orange |
    #yellow |
    #green  |
    #blue   |
    #indigo |
    #violet

let favorite_color := #yellow
let not_true := #false

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

Records

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

type Address_Info := (
    number: Int,
    name: Str,
    address: Str,
    okay: Boolean
)
type Color_Record := (color: Color, index: Int)

let example_record := (number: 5, name: "Bob", address: "4121 Roanoke Rd", okay: #true)
let example_color := (color: #red, index: 33)

This illustrates that the syntax of types closely tracks the syntax of literals, an idea known as homoiconicity.

And, again, the types of example_record and example_color are unambiguous. Note that the type of example_color is not exactly Color_Record, rather it is (color: #red, index: Int) which is type compatible with Color_Record.

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:

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_Ints := [Int]
type Set_of_Strings := {Str}
type Map_of_Strings_to_Info := {Str: Address_Info}
type Map_of_Info_to_Strings := {Address_Info: Str}

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

Some small notes:

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

let record_example := (name: "Fred", address: "1055 Nermish Rd")
let map_example := {"name": "Fred", "address": "1055 Nermish Rd"}

They are not the same, though, because map_example can only have String values, whereas record_example can have arbitrary attribute types. In addition, as we’ll see, the record_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 record must2 be fixed. That is, this is invalid:

let wont_compile := ((foo + bar): 33)

If a record 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.

Unions

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

type Return_Type :=
    ok ~ Address_Info    |
    file_not_found ~ Str |
    #access_denied       |
    #divide_by_zero      |
    #other_error

type Optional_Name := name ~ Str | #missing

let example_error := #access_denied
let example_not_found := file_not_found ~ "/home/user/record.json"
let 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 hashtag means

We mentioned the hashtags under Enumerated types, and as you can see, we can combine hashtag entries with other entries. In a nutshell, the above definitions are simply shorthand for this:

type Return_Type :=
    ok ~ Address_Info    |
    file_not_found ~ Str |
    access_denied ~ ()   |
    divide_by_zero ~ ()  |
    other_error ~ ()

type Optional_Name := name ~ Str | missing ~ ()

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

The empty record type () is called Unit and has a single possible value, (), so it’s a handy placeholder.

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:

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

In the future:

Functions

We can define a function like so:

func 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.

func simple(first: Int, second: Int, third: Int) -> Int {
    return first * second + third
}

let 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. Positional syntax is allowed for a limited number of arguments, 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.

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

Control statements

Tenet supports some of the standard control statements.

func control(list_of_things) {
    let fives := 0
    for item in list_of_things {
        if item.a == 5 {
            let fives += 1
        }
    }

    return fives
}

It doesn’t allow unbound looping through while or through recursion; all user-defined functions in Tenet are convergent. In practice, it’s quite easy to create a function that is effectively divergent, but makes complexity analysis straightforward.

It also 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.

func 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
    else:
        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.

func wont_work(left, right) {
    switch left {
    case right:
        return right
    else:
        return 10
    }
}

func will_work(left, right) {
    if left == right {
        return right
    } else {
        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.

let (a, b, c) := (a: 1, b: 2, c: 3)
# Same as:
let t := (a: 1, b: 2, c: 3)
let a := t.a
let b := t.b
let c := t.c

# Renaming
let (a: alpha, b: beta, c: gamma) := t
# Same as:
let alpha := t.a
let beta := t.b
let 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.

let x := (a: [1, 2, 3])
let x.a[1] := 5
let x := (a: [1, 5, 3])

Modules

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. (Needs updates based on new stuff in the spec.)

import path.path.path (name, name as rename, name, name)

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 record attribute names be fixed may be lifted in the future via metaprogramming facilities. 

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