Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Fundamental Types

Graphix has a few fundamental data types, the Graphix shell is a good way to explore them by trying out small Graphix expressions. You can run the Graphix shell by invoking graphix with no arguments.

Numbers

i8, u8, i16, u16, i32, u32, i64, u64, f32, f64, and decimal are the fundamental numeric types in Graphix. Literals are written with their type prefixed, except for i64 and f64 which are written bare. for example, u32:3 is a u32 literal value.

decimal is an exact decimal representation for performing financial calculations without rounding or floating point approximation errors.

The basic arithmetic operations are implemented on every number type. Both operands of an operation are one type: an f64 and an i64 do not mix without a cast.

OperationUncheckedChecked
Add++?
Subtract--?
Multiply**?
Divide//?
Mod%%?

Unchecked integer +, - and * wrap on overflow (`i64:9223372036854775807

  • 1is the smallesti64). Unchecked /and%return bottom (no value) on a zero divisor (the interpreter also logs it; compiled code does not, so run with–no-fusionto see the log), and/also on the one quotient that overflows, the smallest signed value divided by-1; x % -1is0`. A bottomed expression simply stops updating until its inputs change to values that produce a valid result.

Checked operators return a union type [T, Error<ArithError(string)>], allowing you to handle arithmetic errors explicitly using ?, $, or select`.

Mixing two numeric types is refused at compile time; cast one side:

〉1. + 1
error: in expr

Caused by:
    0: at: line: 1, column: 1, in: 1.0 + 1
    1: cannot compute f64 + i64: arithmetic is fn<'a: Number + Singleton>(x: 'a, y: 'a) -> 'a — both operands must be one numeric type (cast one side explicitly)
〉1. + cast<f64>(1)$
-: f64
2

With unchecked operators, a division by zero returns bottom (logged by the interpreter only) – the expression produces no value until the inputs change. This means downstream expressions simply stop updating until the arithmetic becomes valid again.

〉0 / 0
-: i64

No value is printed because the division by zero produces bottom. If the divisor later changes to a non-zero value, the expression will resume producing values.

With checked operators, you get an explicit error value you can handle:

〉0 /? 0
-: [i64, Error<`ArithError(string)>]
error:`ArithError("attempt to divide by zero")

You can use ? to propagate, $ to swallow, or select to match on the result:

〉(0 /? 0)?
-: i64
// throws ArithError to the nearest installed catch

〉(0 /? 0)$
-: i64
// logs warning, returns bottom

〉u32:0 -? u32:1
-: [u32, Error<`ArithError(string)>]
error:`ArithError("attempt to subtract with overflow")

v32, z32, v64, z64

These number types are the same as the normal types except when they are sent over the wire via netidx (or written to a file) they use variable width encoding instead of normal encoding. The number of bytes used varies for 64 bit numbers to between 1 and 10. Small numbers (below 127) are encoded in 1 byte, larger number use more bytes. The type correspondence is,

CompactNormal
v32u32
z32i32
v64u64
z64i64

Number Sets

There are a few sets of number types that classify numbers into various kinds. Number being the most broad, it contains all the number types. Int contains only integers, Float the two float types, Real contains only reals (decimal plus the two float types), Sint contains signed integers, Uint contains unsigned integers. A set is a type for a variable to range over ('a: Number); a value of a set type is one of its members, and arithmetic takes one member type at a time.

Bool

Boolean literals are written as true and false, and the name of the boolean type is bool.

Boolean expressions using &&, ||, and ! are supported. These operators only operate on bool. They can be grouped with parenthesis. && and || are strict: they do not short circuit, and an operand that is bottom makes the result bottom (false && never() produces nothing). For example,

〉true && false
-: bool
false
〉true || false
-: bool
true
〉!true
-: bool
false
〉!1
error: in expr

Caused by:
    0: at: line: 1, column: 2, in: i64:1
    1: type mismatch bool does not contain i64

Duration

A time duration. The type name is duration, and the literals are written as, duration:1.0s, duration:1.0ms, duration:1.0us, duration:1.0ns (and m, h, d, M and y). The arithmetic operators do not apply to durations; sys::time does the arithmetic.

〉sys::time::add_dur(duration:1.0s, duration:1.0s)$
-: duration
2.s
〉sys::time::scale(duration:1.0s, 50.0)$
-: duration
50.s
〉sys::time::scale(duration:1.0s, 0.02)$
-: duration
20.ms

DateTime

A date and time in the UTC time zone. The type name is datetime and literals are written in RFC3339 format inside quotes. For example, datetime:"2020-01-01T00:00:00Z". sys::time::add and sys::time::sub move a datetime by a duration, and sys::time::diff is the duration between two.

〉sys::time::add(datetime:"2020-01-01T00:00:00Z", duration:30.s)
-: datetime
2020-01-01 00:00:30 UTC

You can enter datetime literals in local time and they will be converted to UTC. For example,

〉datetime:"2020-01-01T00:00:00-04:00"
-: datetime
2020-01-01 04:00:00 UTC

String

Strings in Graphix are UTF8 encoded text. The type name is string and the literal is written in quotes "this is a string". The escapes are \n, \r, \t, \0, \\, \", and \[ / \] for a literal bracket (a bare [ starts an interpolation): "this is \" a string with a quote and a \n". Non printable characters such as newline will be escaped by default when strings are printed to the console, you can use print to print the raw string including control characters.

String Interpolation

String literals can contain expressions that will be evaluated and joined to the string, such expressions are surrounded by unescaped [] in the string. For example,

〉let row = 1
〉let column = 999
〉"/foo/bar/[row]/[column]"
-: string
"/foo/bar/1/999"

Values in an interpolation need not be strings, they are printed into the string when they are used. You can write a literal [ or ] in a string by escaping it.

〉"this is a string with a \[ and a \] but it isn't an interpolation"
-: string
"this is a string with a [ and a ] but it isn't an interpolation"

String literals may span multiple lines; the newlines become part of the string.

Triple Quoted Strings (Templates)

A string delimited by """ is the TEMPLATE form: literal text is the common case there, so the marking flips relative to a normal string. Bare ", [, and ] are all plain content, and interpolation is the marked thing — a splice is written \[expr]. That makes templates the natural home for blocks of quoted or bracket-heavy text — embedded code, shell, TUI layouts — with reactive values spliced in:

let name = "world";
let page = """
say "hello" to \[name]
keys: [a]pprove · [d]eny
shell embeds verbatim: [ -z "$d" ] && x=$1
value = \[1 + 1]
"""

The rule is a symmetric inversion, easy to remember: in strings, interpolation is bare and literal brackets are marked; in templates, brackets are bare and interpolation is marked.

One newline immediately after the opening """ is stripped, so the template’s first line does not have to share a line with the delimiter. The content ends at the first unescaped """; to include a literal """, escape one of the quotes (\"""), and a " that would touch the closing delimiter must likewise be escaped ("""ends in \""""). The other escapes (\n, \t, \\, \") work as in normal strings; \] is an error (a bare ] is always writable), and a literal backslash-then-bracket is \\[.

Raw Strings

A raw string is written r"…", or with hashes — r#"…"#, r##"…"##, and so on — exactly as in Rust. There are NO escapes and no interpolation: the content is verbatim, ending at the first " followed by the opener’s hash count. Any string at all can be written by choosing enough hashes. This is the form for embedding scripts, regexes, or other bracket- and backslash-heavy text:

let pat = r"[\[\]0-9]+";
let script = r#"
awk '{ print "field:", $1 }'
"#

Raw strings do not strip any newlines: the content is exactly what is between the delimiters.

Any

The Any type is a type that unifies with any other type, it corresponds to the underlying variant type that represents all values in Graphix (and netidx). It is not used very often, as it provides very few guarantees, however it has it’s place. For example, queue’s #clock argument is Any: any value at all can tick it. Usually the first thing you do with a value of type Any is call cast to turn it into the type you expect (or an error), or use a select expression to match its type (more on select later). A cast keeps a value’s kind: a number converts to another number (an integer type only when it holds the value exactly), and a union target takes a member of the value’s own kind first, so cast<[i8, Array<f64>]>([1, 2]) is [1., 2.]. Across kinds a cast only parses a string (cast<i64>("42"), cast<bool>("true")), prints a number, bool, datetime or duration plainly (cast<string>(42) is "42"), converts between a number and a datetime (Unix seconds) or a duration (seconds), turns a bool into a number (1 or 0), or reads an array of numbers as bytes; anything else, such as cast<bool>(1), cast<string>([1, 2]) or cast<Array<i64>>(5), is an InvalidCast error. Reading data whose type the program cannot see, a netidx subscription or a json document, is type-directed instead: sys::net::subscribe returns the type you annotate and casts what arrives to it.

Null

Null is nothing, just like in many other languages. Unlike most other languages null is a type not a catch all. If the type of a value does not include null then it can’t be null. The set ['a, null] (alias Option<'a>) is commonly used to represent things that will sometimes return null.

Array

Arrays are immutable, contiguous, and homogeneous. They are parameterized, Array<string> indicates an array of strings. Arrays are zero indexed a[0] is the first element. Array elements can be any type, including other arrays at arbitrary levels of nesting. There is a special array type that acts like Array<Any>, that represents the fundamental array type in the underlying value representation. Array literals are written like [x, y, z]. There are many functions in the array module of the standard library for working with arrays.

Array Slicing and Indexing

Graphix supports array subslicing, the syntax will be familiar to Rust programmers.

  • a[2..] a slice from index 2 to the end of the array
  • a[..4] a slice from the beginning of the array to index 3
  • a[1..3] a slice from index 1 to index 2
  • a[-1] the last element in the array
  • a[-2] the second to last element in the array
  • a[-2..] the last two elements of the array
  • a[..-1] every element but the last

A negative index or slice bound counts from the end of the array. A bound past either end is an out-of-bounds error, as is a slice whose start comes after its end.

..= is not supported however, the second part of the slice will always be the exclusive bound. Literal numbers can always be replaced with a Graphix expression, e.g. a[i..j] is perfectly valid.

Mutability and Implementation

Arrays are not mutable, like all other Graphix values. All operations that “change” an array, actually create a new array leaving the old one unchanged. This is even true of the connect operator, which we will talk more about later.

There are a couple of important notes to understand about the implementation of Arrays.

  • Arrays are memory pooled, in almost all cases (besides really huge arrays) creating an array does not actually allocate any memory, it just reuses a previously used array that has since become unused. This makes using arrays a lot more efficient than you might expect.

  • Arrays are contiguous in memory. This means they are generally very memory efficient, each element is 2 machine words, and O(1) to access. However there are a few cases where this causes a problem, such as building up an array by appending one element at a time. This is sadly an O(n^2) operation on arrays. You may wish to use another data structure, such as a map (which would be O(n log(n)) if you need to build a large data structure this way.

  • Array slices are zero copy, and also pooled. They simply create a light weight view into the array. This means algorithms that progressively deconstruct an array by slicing are O(N) not O(N^2) and the constants are very fast.

List

List<'a> is an immutable singly linked list. Lists complement arrays: an array is contiguous — compact and O(1) to index, but O(n²) to build by appending one element at a time — while a list is built front to back one O(1) cons at a time, and two lists with a common tail share memory. When you are accumulating an unknown number of elements, cons onto a list as you go and convert once at the end (list::to_array, or list::to_array_rev to undo the reversal that front-consing produces, in one walk).

List literals are written like array literals with angle brackets added,

let l = [<1, 2, 3>];
l
$ graphix test.gx
[<1, 2, 3>]

The empty list is [<>]. On its own it usually needs a type annotation, since there is no element to infer the element type from,

let empty: List<i64> = [<>]

Elements can be any type, including other lists. The list module in the standard library has the functions for working with lists — list::cons, list::len, list::map, list::fold and many more — and lists are destructured with list patterns in select.

Mutability and Implementation

Like all Graphix values lists are immutable: consing onto a list creates a new list whose tail is the old list, unchanged and fully shared — this is what makes lists cheap to build incrementally. The cells are pooled like arrays, so building a list does not usually allocate. Indexed access (list::nth) walks the spine and is O(n); if you need random access, convert to an array.

Lists as Data

Data carries no list cells: every writer (json, toml, pack, netidx) writes a list as a flat array, so pack::write_bytes([<1, 2, 3>]) is the same bytes as pack::write_bytes([1, 2, 3]). A typed read or a cast to a List builds the list from the array again, so what a writer emits reads back into its type, and a cast of an Any holding an array of rows reads the list directly:

let v: Any = [[1, 2], [3, 4]];
cast<List<Array<i64>>>(v)

is [<[1, 2], [3, 4]>].

Tuples

Tuples are written (x, y), they can be of arbitrary length, and each element may have a different type. Tuples may be indexed using numeric field indexes. Consider

let x = (1, 2, 3, 4);
x.0

Will print 1.

Map

Maps in Graphix are key-value data structures with O(log(N)) lookup, insert, and remove operations. Maps are parameterized by their key and value type, for example Map<string, i64> indicates a map with string keys and integer values. There are many functions for working with maps in the map standard library module

Map Literals

Maps can be constructed using the {key => value} syntax:

〉{"a" => 1, "b" => 2, "c" => 3}
-: Map<'_1893: string, '_1895: i64>
{"a" => 1, "b" => 2, "c" => 3}

Keys and values can be any Graphix type, for example here is a map where the key is a Map<string, i64>.

{{"foo" => 42} => "foo", {"bar" => 42} => "bar"}
-: Map<'_1919: Map<'_1915: string, '_1917: i64>, '_1921: string>
{{"bar" => 42} => "bar", {"foo" => 42} => "foo"}

Map Indexing

Maps can be indexed using the map{key} syntax to retrieve values:

〉let m = {"a" => 1, "b" => 2, "c" => 3}
〉m{"b"}
-: ['_1907: i64, Error<`MapKeyError(string)>]
2

If a key is not present in the map, indexing returns a MapKeyError:

〉m{"missing"}
-: ['_1907: i64, Error<`MapKeyError(string)>]
error:`MapKeyError("map key \"missing\" not found")

Mutability and Implementation

Like all Graphix values, maps are immutable. All operations that “change” a map actually create a new map, leaving the original unchanged. Maps are memory pooled and very efficient - creating new maps typically reuses existing memory rather than allocating new memory.

Maps maintain their key-value pairs in a balanced tree structure, ensuring O(log(N)) performance for all operations regardless of map size.

Chaining Access Operators

The postfix access operators — struct field .name, tuple index .0, array index/slice [i] / [i..j], map index {key}, and function call (args) — can be chained directly, left to right, without intermediate parentheses:

// field, then index, then field
data.users[0]$.name
// map index, then field
config{"db"}$.port
// nested array index
matrix[i]$[j]$
// tuple index, then field
point.0.x
// a call whose result is itself called
make_handler(cfg)(event)

Each operator applies to the value produced by everything to its left, so a chain reads in evaluation order. Array and map indexing return a Result, so the ? or $ that unwraps one sits right after that access, before the chain goes on: data.users[0]?.name propagates an error from the indexing.

Parentheses are only needed when the source of an access is not itself a simple value or another access, for example (a, b).0 (index into a tuple literal). A bare identifier or an existing chain never needs them.

Error

Error is the built in error type. It carries a type parameter indicating the type of error, for example Error<MapKeyError(string)>is an error that carries a ``MapKeyError `` variant. You can access the inner error value using e.0 e.g.,

〉let e = error(`MapKeyError("no such key"))
〉e.0
-: `MapKeyError(string)
`MapKeyError("no such key")

More information about dealing with errors is available in the section on error handling.