Datalove Literals

Datalove Literals is a typed declarative expression language for serializing, storing and transmitting common data types. We often refer to it as datalit, and its types as datalit types.

Datalove Literals is the data sublanguage of Datalove: every datalit expression is written the same way in Datalove, where it means the same value. Datalit has no names, no operators and no computation. Datalit files use the .dlt extension. The format is intended for use in the Datalove ecosystem and not as a general-purpose serialization format.

This document is an informal specification for Datalove Literals and serves as a gentle introduction to the data model and syntax of Datalove in general.

Contents

An example

{
  name = "datalove",
  version = (0, 1, 0),
  unit = (),
  enabled = true,
  score = 99.5,
  flags = 0xFF,
  tags = ["fast", "typed", "portable"],
  counts = %{ "a" = 1, "b" = 2, "c" = 3 },
  ids = #{ 10, 20, 30 },
  matrix = [| 1.0 0.0, 0.0 1.0 |],
  cube = [|
    1 2,
    3 4,,
    5 6,
    7 8
  |],
  config = some {
    retries = 3,
    timeout = 30.0,
  },
  metrics_table = {|
    name, value
    "latency", 0.5
    "throughput", 1000.0
    "duration", 25.0
  |},
  backup = : ?string / none,
  status = ok "healthy",
  failure = : !string / er error "oops",
  state = atom Ready,
  event = term Click (100, 200),
  kind = : enum {
    atom Observation,
    term Custom string,
  } / enum {
    term Custom "experiment",
  },
  payload = data [1, 2, 3],
}

Lexical structure

Source text is UTF-8.

Whitespace is any Unicode whitespace character. It separates tokens and is otherwise insignificant, with two exceptions: newlines separate table rows, and spaces separate the elements of a tensor row. A newline is \n; a \r before it is ordinary whitespace. A ; is a line break written on the line: it is a newline everywhere, separating table rows and otherwise insignificant.

Comments are either // to end of line, or /* ... */, which nest. Comments are whitespace.

Identifiers name struct fields, table columns, and atom, term and enum variants. An identifier is a run of ASCII letters, digits and _ not beginning with a digit. Strings and comments may hold any text, but names are ASCII: café and x² are errors.

These words start an expression:

true  false  none  some  ok  er  data  error  atom  term  enum

In type position the primitive type names, bool, int, string, u8 .. u64, i8 .. i64, index, offset, f32, f64, data, error, and atom, term and enum are types or start one.

No word is reserved everywhere. Datalit declares no names, and a struct field, table column or variant may be any identifier, since only a name can appear there: { none = 1 } is a struct.

Tokens that are written against each other are glued. Gluing decides how far a numeric literal reaches: a number is a maximal glued run of sign, digits, point and exponent, so -1.5e-7 is one literal and - 1, 1 . 5 and 2.5e - 10 are errors. Letters glued to the end of a number are an error, as datalove has no numeric suffixes: 1u8 is written : u8 / 1. Inside a tensor, gluing is what separates [| 1 -2 |], two elements, from [| 1 - 2 |], an error.

EBNF · Lexical

ws             = { whitespace | newline | comment } ;
whitespace     = ? any Unicode whitespace character ? ;
newline        = "\n" | ";" ;
comment        = line_comment | block_comment ;
line_comment   = "//", { ? any char except newline ? } ;
block_comment  = "/*", { block_comment | ? any char ? }, "*/" ;

ident          = ident_start, { ident_start | digit } ;
ident_start    = "a" .. "z" | "A" .. "Z" | "_" ;
digit          = "0" .. "9" ;
hex_digit      = digit | "a" .. "f" | "A" .. "F" ;

Expressions

All expressions may be prefixed with a type hint, : type / expr.

Expressions are typechecked with a simple bidirectional discipline, drawing their expected type from a type hint or synthesizing a type directly. Most expressions synthesize types unambiguously. Type hints are sometimes required, in predictable positions. For many uses datalit expressions are checked against an external type context.

// A single top-level type hint.
: {
  name: string,
  version: (u32, u32, u32),
  tag: (bool,),
  counts: %{string = int},
} / {
  name = "datalove",
  version = (0, 1, 0),
  tag = (true,),
  counts = %{ "a" = 1, "b" = 2, "c" = 3 },
}
// Embedded type hints.
{
  name = : string / "datalove",
  limits = {
    low = 1,
    // A deeply-embedded hint.
    high = : u32 / 3,
  },
  kind = : enum {
    atom ProcessData,
    term Custom string,
  } / enum {
    term Custom "experiment",
  },
}

Types are generally spelled the same way as their values, but with the type sigil, :, replaced with the value-assignment sigil, =.

: {
  retries: int,
  timeout: f32,
} / {
  retries = 3,
  timeout = 30.0,
}

A hint covers the one expression after its /. An expression carries at most one hint; : u32 / : u32 / 1 does not parse. Parentheses group without changing meaning: (1) is 1, and (: u32 / 1) is : u32 / 1.

A type hint may not name a type beyond the built-ins: datalit has no type aliases, so any bare name in a type is an error. Collections are written with their sigils, and tuple, list, map, set, table and tensor are not types.

A datalit document is a single expression. Nothing but whitespace may follow it.

Anything written between brackets and separated by commas, in a value or a type, may end with a comma: [1, 2,], { x = 1, }, : [u32,] / [], enum { atom A, }. The comma closes the last item and means nothing more, so one is allowed and two are not. Tensors are the exception, as their commas carry shape; see Tensor.

EBNF · Expressions

datalit        = ws, full_expr, ws ;
full_expr      = [ type_hint, ws ], expr ;
type_hint      = ":", ws, type, ws, "/" ;
type           = primitive_type
               | tuple_type
               | struct_type
               | option_type
               | result_type
               | list_type
               | map_type
               | set_type
               | table_type
               | tensor_type
               | atom_type
               | term_type
               | enum_type
               | dynamic_type ;
expr           = primitive_lit
               | group_expr
               | tuple_expr
               | struct_expr
               | option_expr
               | result_expr
               | list_expr
               | map_expr
               | set_expr
               | table_expr
               | tensor_expr
               | atom_expr
               | term_expr
               | enum_expr
               | dynamic_expr ;
group_expr     = "(", ws, full_expr, ws, ")" ;

Primitive types

Type Literal
bool true, false
int 42
f64 3.14, 1.0e10
f32 : f32 / 3.14
string "hello"
u8 .. u64 : u32 / 42
i8 .. i64 : i32 / -1
index : index / 0
offset : offset / 0

index is an unsigned integer representing the addressable size of collection types, offset is the signed version of the same size, both 32-bit by default, 64-bit by compile-time option. They are distinct types from the fixed-width integers of the same size.

Integers

Bare integer literals synthesize as int, the big integer type, which has no range limit. Fixed-width integers require a type hint or checking context, and a literal out of the range of the type it checks against is an error.

Underscores may group digits and say nothing about the value. A separator goes between two digits: 1_000_000 is an integer, 1_ is an error, and _1 is an identifier.

A sign is written glued to the digits: -1. There is no + sign. -0 is zero.

Floats

A float literal is written with a point, an exponent, or both: 42.0, 4.2e1, 1e-7, 6.022E23. Digits are required on both sides of a point, so 1. and .5 are errors. The exponent marker is e or E, and its sign is optional. Separators group digits in any run: 1_0.000_1e1_0.

Float literals synthesize f64, and check against f32 or f64. An integer literal does not check against a float type, nor a float literal against an integer type.

A decimal literal becomes the float nearest its exact value, ties to even, converted directly to the target width. A literal too large for the target width becomes infinity: : f32 / 1e300 is positive infinity. -0.0 is negative zero.

Hex literals

Integers and floats can be created from hex literals, written 0x or 0X and hex digits of either case, which may be grouped with underscores. Hex literals synthesize int, but check as int, unsigned fixed ints, index, or floats if provided a type hint. Hex literals do not check against signed fixed-width types or offset. A hex literal takes no sign: -0x10 is an error. Write a negative number in decimal.

A hex literal checked against a float type is its IEEE 754 bit pattern, and must fit in the float's width: 32 bits for f32, 64 for f64. This is the only way to write bit-exact floats, NaNs and infinity.

: {
  foo: int,
  bar: u32,
  baz: f32,
  inf: f64,
} / {
  foo = 0x01,
  bar = 0x02,
  baz = 0x3F80_0000,         // 1.0
  inf = 0x7FF0000000000000,
}

Strings

A string literal is written between double quotes, and holds any characters but an unescaped " or \, including literal newlines. The value is a sequence of Unicode scalar values. No normalization is performed.

Escape Character
\" double quote
\\ backslash
\n line feed
\r carriage return
\t tab
\0 NUL
\u{H..} Unicode scalar value, 1 to 6 hex digits

Any other escape is an error, as is a \u{..} naming a surrogate or a value above 10FFFF.

EBNF · Primitive types

primitive_type = "bool"
               | "u8" | "u16" | "u32" | "u64"
               | "i8" | "i16" | "i32" | "i64"
               | "index" | "offset"
               | "f32" | "f64"
               | "int" | "string" ;

primitive_lit  = bool_lit | numeric_lit | string_lit ;
bool_lit       = "true" | "false" ;
numeric_lit    = float_lit | hex_lit | int_lit ;
int_lit        = [ "-" ], digit_run ;
float_lit      = [ "-" ], digit_run,
                 ( ".", digit_run, [ exponent ] | exponent ) ;
exponent       = ( "e" | "E" ), [ "+" | "-" ], digit_run ;
hex_lit        = "0", ( "x" | "X" ), hex_run ;
digit_run      = digit, [ { digit | "_" }, digit ] ;
hex_run        = hex_digit, [ { hex_digit | "_" }, hex_digit ] ;

string_lit     = '"', { string_char }, '"' ;
string_char    = escape_seq | ? any char except '"' and '\' ? ;
escape_seq     = "\", ( '"' | "\" | "n" | "r" | "t" | "0"
                      | "u{", hex_digit, { hex_digit }, "}" ) ;
                      (* 1 to 6 hex digits *)

A numeric literal contains no whitespace or comments.

Collections

Name Type Literal
list [T] [1, 2, 3]
map %{ K = V } %{ 0 = 5, 1 = 2 }
set #{ K } #{ 1, 2, 3 }
table {| col1: T1, col2: T2 |} {| col1, col2; 1, 2; 3, 4 |}
tensor [|T, N|] [| 1 2 3, 4 5 6 |]

An empty collection leaves its element type open, so it does not synthesize a type: [], #{}, %{}, an empty tensor, and a table with no rows need a type hint or checking context, as none does. : [u32] / [] is valid, and [] alone is an error.

List

An ordered sequence of homogeneous elements.

[1, 2, 3]                      // [int]
: [u32] / [1, 2, 3]            // [u32] via type hint
: [string] / []                // [string], empty with hint
[: u8 / 1, 2]                  // [u8]

All elements have the same type. Without an expected type, the elements are typed together: literals go along with any element that has a definite type, and take their defaults if none does. [: u8 / 1, 2] is a [u8], [some 1, none] is a [?int], and [: u8 / 1, : u16 / 2] is an error.

Map

A key-value mapping, ordered by key.

%{ "a" = 1, "b" = 2 }          // %{string = int}
: %{u32 = string} / %{ 1 = "x", 2 = "y" }
: %{string = u32} / %{}        // empty map

Entries use = to separate keys from values, same as struct field assignment. All keys must have the same type, and all values must have the same type. Any type may be a key.

A map's entries are kept in the total ordering of their keys, not in the order they were written: %{ 2 = 0, 1 = 0 } and %{ 1 = 0, 2 = 0 } are the same map. A key written more than once keeps the last value written for it.

Set

A collection of unique elements, ordered by value.

#{ 1, 2, 3 }                   // #{int}
: #{u32} / #{ 10, 20, 30 }
: #{u32} / #{}                 // empty set

All elements must have the same type. Any type may be an element. A set's elements are kept in their [total ordering], and an element written more than once is held once.

Table

A data structure with named, typed columns.

: {| x: int, y: string |} / {|
  x, y
  1, "a"
  2, "b"
|}

The first row names the columns; subsequent rows provide data. Rows are delimited by newlines or semicolons.

: {| x: int, y: int |} / {| x, y; 1, 2; 3, 4 |}

Without a type hint, each column's elements are typed together, as a list's are, and the table synthesizes those column types: {| x; 1; 2 |} is a {| x: int |}. A table with columns and no rows leaves its column types open, so it needs a type hint. Checked against a table type, the column names in the literal must match the type's in order. Each column is named by exactly one identifier, and no two columns share a name. Each data row must have exactly as many values as there are columns. A table with no rows is written with just its header row, and the table with no columns, of type {| |}, is written {| |}.

A semicolon is a newline, so a run of them is blank lines, which are free: {| x, y;; 1, 2; |} is a table with one row. A comma with no column before it is an error: {| x,, y |} does not parse. A comma at the end of a row closes the value before it and is fine.

Tensor

A multi-dimensional array with fixed shape, typed by element type and rank.

[| 1 2 3 |]                    // 1D, shape [3]
[| 1 2 3, 4 5 6 |]             // 2D, shape [2, 3]
[| 1 2, 3 4,, 5 6, 7 8 |]      // 3D, shape [2, 2, 2]
: [|u32, 2|] / [| 1 2, 3 4 |]  // typed: element u32, rank 2
: [|u8, 1|] / [| |]            // empty tensor, rank 1, shape [0]
[| 2 3 | 1 2 3 4 5 6 |]        // shape [2, 3], given in a header
: [|u8, 2|] / [| 0 3 | |]      // empty tensor, shape [0, 3]

Shape is inferred from the multi-comma structure: spaces separate elements along the innermost axis, , separates rows (2nd axis), ,, separates slabs (3rd axis), ,,, separates blocks (4th axis), and so on. The rank is one more than the longest run of commas written. Whitespace between the commas of a run does not break it: , , is ,,. Every group along an axis must have the same shape, so the element count equals the product of the shape dimensions.

Higher-dimensional tensors benefit from multiline layout, using blank lines to visually separate the higher axes:

// 3D tensor with shape [2, 3, 3]
: [|f64, 3|] / [|
  1.0 0.0 0.0,
  0.0 1.0 0.0,
  0.0 0.0 1.0,,

  2.0 0.0 0.0,
  0.0 2.0 0.0,
  0.0 0.0 2.0
|]

A comma run goes between two parts and nowhere else. One with nothing before it or after it separates nothing, so [| ,1 2 |] and [| 1 2, |] do not parse: unlike every other bracketed list, a tensor takes no trailing comma, since its commas say its shape. Blank lines are whitespace here and mean nothing to the shape, which is what lets the layout above breathe.

Some shapes cannot be shown by separators: an axis of extent 0 above the innermost one has no parts to separate, and a leading axis of extent 1 has no separator to show it. These are written with a shape header, the extents as whole numbers and then a |:

[| 1 3 | 1 2 3 |]              // shape [1, 3]
: [|u8, 2|] / [| 0 3 | |]      // shape [0, 3], empty
: [|u8, 2|] / [| 2 0 | |]      // shape [2, 0], empty, and not the same value
[| 2 2 | 1 2 3 4 |]            // shape [2, 2], body written flat
[| 2 2 | 1 2, 3 4 |]           // shape [2, 2], body shaped as the header says

Any tensor may have a header. Under one, the body is written flat, with no commas, filling the shape in row-major order, or shaped exactly as the header says; a body shaped any other way is an error. A header is the simple way to write out a large tensor: the extents, a |, and every element.

A tensor has at least one axis. There are no rank-0 tensors, so neither [|T, 0|] nor an empty header, [| | 7 |], is accepted.

The type specifies element type and rank: [|T, N|]. Shape is not part of the type — two tensors of the same element type and rank but different shapes have the same type. All elements have the same type, and are typed together as a list's are. An empty tensor needs a type hint for its element type; its rank is its shape's, hint or not.

EBNF · Collections

list_type      = "[", ws, type, [ ws, "," ], ws, "]" ;
map_type       = "%{", ws, type, ws, "=", ws, type, [ ws, "," ], ws, "}" ;
set_type       = "#{", ws, type, [ ws, "," ], ws, "}" ;
table_type     = "{|", ws, [ type_field_list ], ws, "|}" ;
tensor_type    = "[|", ws, type, ws, ",", ws, rank, [ ws, "," ], ws, "|]" ;
rank           = digit, { digit } ;  (* not zero *)

list_expr      = "[", ws, [ expr_list ], ws, "]" ;
map_expr       = "%{", ws, [ entry_list ], ws, "}" ;
set_expr       = "#{", ws, [ expr_list ], ws, "}" ;
table_expr     = "{|", tbl_ws,
                 [ table_header,
                   { ws, newline, tbl_ws, table_row } ],
                 tbl_ws, "|}" ;
tensor_expr    = "[|", ws, [ tensor_header ], [ tensor_body ], ws, "|]" ;
tensor_header  = extent, { ws, extent }, ws, "|", ws ;
extent         = digit_run ;

expr_list      = full_expr, { ws, ",", ws, full_expr }, [ ws, "," ] ;
entry_list     = entry, { ws, ",", ws, entry }, [ ws, "," ] ;
entry          = full_expr, ws, "=", ws, full_expr ;

table_header   = ident, { ws, ",", ws, ident }, [ ws, "," ] ;
table_row      = full_expr, { ws, ",", ws, full_expr }, [ ws, "," ] ;
tbl_ws         = { ws | newline } ;

tensor_body    = tensor_row, { ws, comma_run, ws, tensor_row } ;
tensor_row     = full_expr, { ws, full_expr } ;
comma_run      = ",", { ws, "," } ;

Within a table, outside any brackets nested in it, ws does not include newlines, which separate rows; a newline inside a block comment is not one. Some rules for these forms cannot be expressed in EBNF:

Aggregates

Name Type Literal
unit () ()
1-tuple (T1,) (true,)
n-tuple (T1, T2) (true, 42)
struct { x: T1, y: T2 } { x = 1, y = 2 }
option ?T some 1
none
result !T ok 1
er error 2
atom atom Foo atom Foo
term term Foo T term Foo 1
enum enum { atom A, term B T } enum { atom A }

The payload of some, ok, er, term, data and error is one expression, which may carry its own hint: some : u32 / 1.

Tuple

An ordered sequence of heterogeneous values. Two tuples are the same type if they have the same length and element types in the same order.

()                             // unit: the zero-element tuple
(true,)                        // 1-tuple (trailing comma required)
(true, 42)                     // (bool, int)
(1, "hello", 3.14)             // (int, string, f64)
: (u32, i32) / (1, -1)         // with type hint

Unit () is both a type and a value. A 1-tuple requires a trailing comma to distinguish it from a parenthesized expression. Types have no grouping parentheses, so in a type (T) is the same 1-tuple type as (T,). Each element synthesizes its type independently: (true, 42, 3.14) synthesizes as (bool, int, f64).

Struct

A collection of named fields. Two structs are the same type if they have the same field names, types, and order.

{ x = 1, y = 2 }               // {x: int, y: int}
: { x: u32, y: f32 } / { x = 1, y = 2.0 }
{}                             // the empty struct

Types use : between field names and types, while expressions use = between field names and values. Field order matters — {x: u32, y: bool} and {y: bool, x: u32} are different types. Checked against a struct type, a struct literal must give every field, in the type's order. Field names are unique within a struct. Each field value synthesizes its type independently.

Option

An optional value: either present or absent. The type is written ?T with a prefix ?.

some 42                        // ?int
some "hello"                   // ?string
: ?u32 / some 1
: ?u32 / none
: ??u32 / some none

some wraps a value; none represents absence. some e synthesizes as ?T where T is the type of e. none cannot synthesize a type — it requires a type hint or checking context. A value does not check against an option type without some: : ?u32 / 1 is an error.

Result

A success-or-failure value. The type is written !T with a prefix !. The failure side always holds an error.

ok 42                          // !int
: !u32 / ok 1
: !u32 / er error "oops"

ok wraps a success value; er wraps an error. ok e synthesizes as !T where T is the type of e. er cannot synthesize a type — it requires a type hint or checking context. The payload of er is checked against error, so it is written as an error expression, er error "oops", and is the error the result holds. An error is not a result by itself: : !u32 / error "oops" is an error.

Atom

A named unit type with no payload. An atom is both a type and a value — the syntax is the same in both positions.

atom Red                       // type and value
atom Blue

Two atoms are the same type if they have the same name.

Term

A named type carrying a typed payload.

term Foo 42                    // term Foo int (value)
term Bar "hello"               // term Bar string (value)

In type position, the payload is a type: term Foo int. In expression position, the payload is a value: term Foo 42. Two terms are the same type if they have the same name and the same payload type.

Enum

A closed union of atom and term variants.

: enum { atom Red, atom Blue, term Custom string } /
  enum { atom Red }

: enum { atom Red, atom Blue, term Custom string } /
  enum { term Custom "hello" }

The type lists all variants; the expression provides a single variant, which must be one the type lists. Variant names are unique within an enum. Enum type equivalence compares variants by name, regardless of the order they are declared. Enum literals require a type hint.

An atom or term checks against any enum that lists it, so the enum { } around a variant may be left off where the enum type is expected:

: [enum { atom Red, atom Blue, term Custom string }] /
  [atom Red, term Custom "hello", enum { atom Blue }]

This is not subtyping. An atom literal is a value of every enum that lists it, as 5 is a value of every integer type that holds it, and in both cases it is the literal that is flexible, not the type: an atom Red value is not an enum value, as a u8 value is not an int value, and in Datalove each is converted with @.

EBNF · Aggregates

tuple_type     = "(", ws, [ type_list ], ws, ")" ;
struct_type    = "{", ws, [ type_field_list ], ws, "}" ;
option_type    = "?", type ;
result_type    = "!", type ;
atom_type      = "atom", ws, ident ;
term_type      = "term", ws, ident, ws, type ;
enum_type      = "enum", ws, "{", ws, enum_variant_list, ws, "}" ;

tuple_expr     = "(", ws, ")"
               | "(", ws, full_expr, ws, ",", ws, ")"
               | "(", ws, full_expr, ws, ",", ws, expr_list, ws, ")" ;
struct_expr    = "{", ws, [ field_list ], ws, "}" ;
option_expr    = "none" | "some", ws, full_expr ;
result_expr    = ( "ok" | "er" ), ws, full_expr ;
atom_expr      = atom_type ;
term_expr      = "term", ws, ident, ws, full_expr ;
enum_expr      = "enum", ws, "{", ws, ( atom_expr | term_expr ), [ ws, "," ], ws, "}" ;

type_list      = type, { ws, ",", ws, type }, [ ws, "," ] ;
type_field_list= type_field, { ws, ",", ws, type_field }, [ ws, "," ] ;
type_field     = ident, ws, ":", ws, type ;
field_list     = field, { ws, ",", ws, field }, [ ws, "," ] ;
field          = ident, ws, "=", ws, full_expr ;
enum_variant_list = enum_variant, { ws, ",", ws, enum_variant },
                    [ ws, "," ] ;
enum_variant   = atom_type | term_type ;

Dynamic types

Type Literal
data data 1
data : u32 / 2
error error 1
error "oops"

A data value holds a value of any type together with that type. data e synthesizes data, and its payload e must synthesize a type of its own, which is the type the data carries: data 1 holds an int, data : u32 / 1 a u32, and data none and data [] are errors.

An error is the same thing under a different type, used for the failure side of results. error e synthesizes error, and becomes a result's failure under er: er error e.

A value is only a data or error when written as one: : data / 5 is an error, and is written data 5.

EBNF · Dynamic types

dynamic_type   = "data" | "error" ;
dynamic_expr   = ( "data" | "error" ), ws, full_expr ;

Type equivalence

All datalit types are structural: two types are the same when they are spelled the same, with these rules for the aggregates.

Type Same type when
primitives same name; index is not u32 or u64, whatever its width
tuple same length, same element types in order
struct same field names and types, in the same order
table same column names and types, in the same order
list, set same element type
map same key and value types
tensor same element type and rank; shape does not count
option, result same inner type
atom same name
term same name and payload type
enum same set of variants, regardless of declared order

Type synthesis and checking

Every expression either synthesizes a type from itself, or is checked against an expected type from a type hint, enclosing expression, or external type context. An expression that synthesizes can always be checked: it checks against a type if it synthesizes that type. There are no implicit conversions: an integer does not become a float, a value does not become an option, and a u8 does not become a u32. An expression with a hint has the hinted type, and checks only against that type: : {a: u32} / {a = : u8 / 1} is an error.

An expression synthesizes a type only when its form determines the whole type. A literal checks against every type that holds the value it writes, and synthesizes a default: 5 checks against u8 and synthesizes int. An expression whose form leaves part of its type open, like none or [], does not synthesize at all.

Expression Synthesizes Also checks against
true, false bool
integer int fixed-width ints, index, offset, if in range
float f64 f32
hex int unsigned ints, index, f32, f64, if it fits
string string
tuple tuple of element types tuple of same length, elementwise
struct struct of field types struct of same fields in order, fieldwise
list, set of its elements, typed together any element type, elementwise
map of its keys and of its values, each typed together any key and value types, entrywise
tensor of its elements, typed together; rank as written same rank, elementwise
table of each column's elements, typed together same columns in order, cellwise
empty list, set, map — any element types
empty tensor — any element type, same rank
table with no rows — same columns in order
some e ?T where e synthesizes T ?T, checking e against T
none — any ?T
ok e !T where e synthesizes T !T, checking e against T
er e — any !T, checking e against error
data e data
error e error
atom A atom A an enum listing atom A
term A e term A T where e synthesizes T term A T, or an enum listing term A T, checking e against T
enum { v } — an enum listing v

The table with no columns, {| |}, leaves nothing open and synthesizes {| |}.

So a type hint or context is required for none, er, enum literals, empty collections, and fixed-width numbers, index, offset and f32; and for anything containing one of these where it would have to synthesize, as in data [], some none, or a struct { xs = [] } with no hint.

Typing together

Without a hint, a collection's type comes from all of its elements together. Literals like 1, 2.0 and none go along with the rest: if any element has a definite type, such as a hinted one, the literals are read as that type. If nothing decides, the literals take their defaults, int and f64, and none or [] with nothing to go along with needs a hint. The order of the elements doesn't matter.

[1, 2]                         // [int]
[1, : u8 / 2]                  // [u8]
[some 1, none]                 // [?int]
[[], [1]]                      // [[int]]
[none, none]                   // error: nothing says what the option holds
[: u8 / 1, : u16 / 2]          // error: two different types

A map's keys go along with each other, and so do its values, and so do the elements of each table column.

Comparison and total ordering

Every datalit type has a total ordering, so any value can be a map key or set element, and any two values of the same type can be compared. Values of different types are not compared.

The ordering is the one the runtime uses to keep maps and sets, so it is also what decides whether two keys are the same key.

Type Order
bool false before true
integers numeric
floats IEEE 754 totalOrder: -NaN, -inf, negatives, -0.0, 0.0, positives, inf, NaN
string lexicographic by UTF-8 byte, which is code point order
tuple, struct lexicographic by element, in declared order
list lexicographic by element; a prefix comes first
set lexicographic by element, in element order; a prefix comes first
map lexicographic by entry in key order, key then value; a prefix comes first
table lexicographic by row, each row by column in declared order; a prefix comes first
tensor by shape, lexicographically, then by element in row-major order
option none first, then some by payload
result er first, then ok; each by payload
data, error by the carried type, then by value
atom its one value
term by payload
enum by variant name, then by payload

Floats are totally ordered by their bit patterns, so -0.0 and 0.0 are different values and different keys, and a NaN is a value like any other, equal to itself and to no other NaN bit pattern. #{ 0.0, -0.0 } has two elements.

The order among the types a data or error may carry is unspecified. Enum variant names order by UTF-8 byte.

Canonical forms

Datalove Literals has no canonical serialized form as might be used for consistent hashing. There is no canonical form for floats. Will be revisited.

Required type hints

Without an expected type, an expression's type is worked out from its parts. Parts in the same position get one type between them. These share a position:

The type they share is, in order of preference:

  1. the type of any of them that has a definite type: one with a hint, or one whose form fixes its type, as a string, a bool or a data does;
  2. otherwise, the default of the literals among them: int for integers, f64 for floats and for hex beside a float, and an atom's or term's own type, atom A or term A T;
  3. otherwise, none: the parts are only none, er e, empty collections and enum literals, and a hint is required.

A part with nothing else in its position gets its own definite type or its default, and needs a hint if it has neither.

So a value needs a hint, on the part or on anything containing it, wherever this gives a type other than the value's. That happens in exactly these cases:

[1, 2]                         // [int]: defaults, no hint needed
[1, : u8 / 2]                  // [u8]: the hinted element decides
: [u8] / [1, 2]                // [u8]: or one hint on the list
[none, some 1]                 // [?int]: none takes 1's default
[none, some atom Foo]          // [?atom Foo]: and atom Foo's
[[], [1]]                      // [[int]]
[none, some "a"]               // [?string]
[: enum { atom A, atom B } / atom A, atom B]
                               // the hinted element decides; atom B goes along
[1.5, 0x7FF8000000000000]      // [f64]: the NaN goes along with the float
[atom A, atom B]               // error: two defaults, no enum
[none, none]                   // error: nothing has a type

The payload of data and of error is typed on its own, as a whole document is, so nothing outside it says anything about it: : [data] / [data 1] holds an int, and data : u8 / 1 is how to hold a u8.

A hint names exactly the type of what it is on. So a hint at the root of a value covers every part of it but the payloads of its data and error values.

Bugs and open questions

Bugs

Open questions