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:
- A record 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_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:
- 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:
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:
- 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 record and union types.
- Tenet may offer a facility to allow message passing through coroutines.
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)
- 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.