Type System

Structural Type Values

^type returns a structural type value — a first-class value describing the full structure of a collection, not just a coarse type name.

^type Returns Structural Types

[1, 2, 3] => $list
$list.^type == list(number)
# Result: true
[a: 1, b: "hello"] => $d
$d.^type.name
# Result: "dict"
42 => $n
$n.^type == number
# Result: true

Type Constructors

Type constructors produce structural type values. They are primary expressions — valid anywhere an expression is valid.

list(number) => $lt
$lt.^type.name
# Result: "type"
ConstructorExampleProduced Type
list(T)list(number)List-of-number type
dict(T)dict(number)Uniform dict type (all values same type)
ordered(T)ordered(string)Uniform ordered type (all values same type)
tuple(T)tuple(number)Uniform tuple type (all entries same type)
dict(k: T, ...)dict(a: number, b: string)Dict type (fields alpha-sorted in output)
tuple(T, T2, ...)tuple(number, string)Positional tuple type
ordered(k: T, ...)ordered(a: number, b: string)Named ordered type
|p: T| :R|x: number| :stringClosure signature type
stream(T):Rstream(string):numberStream type with chunk type T and resolution type R

Default Values in Type Constructors

Type constructor fields accept a default value using = literal syntax after the field type. When you convert a value with :>, the runtime fills in any missing fields using those defaults.

[b: "b"] -> :>dict(b: string, a: string = "a")
# Result: [a: "a", b: "b"]

The input [b: "b"] omits a. The conversion fills a with "a" from the default.

[x: 1] -> :>ordered(x: number, y: number = 0)
# Result: ordered[x: 1, y: 0]
tuple["x"] -> :>tuple(string, number = 0)
# Result: tuple["x", 0]

Tuple defaults are restricted to trailing positions. You cannot place a defaulted field before a required field in a tuple constructor.

The : assertion operator does not hydrate defaults. Only :> conversion fills missing fields. Use : when you want strict validation with no field synthesis.

When a required field has no default and the input omits it, the runtime raises RILL-R044. See Operators for the full :> compatibility matrix.

Nested Collection Synthesis

When a field is missing with no explicit default, the runtime synthesizes the field if its type is a collection where all children have defaults. The runtime seeds an empty collection and hydrates it.

dict[a: 1] -> :>dict(a: number, b: dict(c: number = 5))
# Result: dict[a: 1, b: dict[c: 5]]

The field b has no value in the input and no explicit default on the field itself. The runtime synthesizes b as an empty dict and fills c from the nested type’s default.

If any child of the nested collection lacks a default, the conversion raises RILL-R044.

Explicit Default Hydration

When a field has an explicit default that is itself a collection, the runtime hydrates that default through the nested type. Child defaults fill any fields the explicit default omits.

dict[] -> :>dict(a: dict(x: number = 1, y: number = 2) = [x: 10])
# Result: dict[a: dict[x: 10, y: 2]]

The explicit default [x: 10] omits y. The runtime fills y with 2 from the nested type constructor.

Defaults in Closure Parameter Annotations

Type constructor defaults also work in closure parameter type annotations. When the caller passes an incomplete value, the runtime fills in missing fields from the annotation defaults.

|a: dict(b: number = 5)| { $a.b } => $fn
$fn(dict[])
# Result: 5

The closure expects a dict with field b defaulting to 5. Calling with an empty dict causes the runtime to fill b from the annotation default.

|a: tuple(number = 0, string = "")| { $a } => $fn
$fn(tuple[])
# Result: tuple[0, ""]

A tuple annotation with trailing defaults fills all missing positions when the caller passes an empty tuple.

Comparing Structural Types

[1, 2, 3] => $list
$list.^type == list(number)
# Result: true
[a: 1, b: "hello"] => $d
$d.^type == dict(a: number, b: string)
# Result: true

Type Inference Cascade

When rill infers the element type of a list literal, it uses a three-level cascade:

  1. Structural match — all elements share the same full structural type. The list retains that type.
  2. Uniform merge — elements share the same compound kind and all their sub-values share a common type. The list retains the uniform form (e.g., list(dict(number))).
  3. Bare type fallback — elements share the same compound kind (e.g., all lists, all closures) but differ in sub-structure. The list uses the bare compound type, stripping the sub-structure.
list[dict[a: 1], dict[b: 2]].^type.signature
# Result: "list(dict(number))"

Both dicts have number values, so the uniform merge succeeds and produces dict(number) as the element type.

[list[1,2], list["a","b"]].^type.signature
# Result: "list(list)"

The inner lists are list(number) and list(string). They share the list kind but differ in element type, so the cascade falls back to bare list, producing list(list).

[|x|($x), |a, b|($a)].^type.signature
# Result: "list(closure)"

The closures have different arities, so the cascade falls back to bare closure.

Any-narrowing applies when one element is an empty collection. An empty list has type list(any). Paired with a concrete element type, the cascade narrows any to that type:

[list[], list[1,2]].^type.signature
# Result: "list(list(number))"

list[] contributes list(any). list[1,2] contributes list(number). The any narrows to number, yielding list(list(number)).

The cascade is recursive. If the bare fallback at one level produces a bare type, the next level applies the same rules:

[list[list[1]], list[list["a"]]].^type.signature
# Result: "list(list(list))"

The outer list sees two list(list(?)) elements where the inner element types differ, so the cascade produces list(list(list)).

If the top-level types are incompatible (e.g., mixing a number and a list), rill raises RILL-R002.

.^type.name for Coarse Type Name

.^type.name returns the coarse type name as a string:

[1, 2, 3] => $list
$list.^type.name
# Result: "list"
[a: 1] => $d
$d.^type.name
# Result: "dict"

Metatype Fixed Point

The ^type of a type value is always type. type.^type is type:

list(number) => $lt
$lt.^type == type
# Result: true
type => $t
$t.^type == type
# Result: true

formatStructure Output Format

The string representation of structural types follows this format:

Value^type string
Any value"any"
Primitive"string", "number", "bool"
List"list(number)", "list(any)", "list(list(number))"
Dict (uniform)"dict(number)" (all values same type)
Ordered (uniform)"ordered(string)" (all values same type)
Tuple (uniform)"tuple(closure)" (all entries same type)
Dict"dict(a: number, b: string)" (fields alphabetically sorted)
Tuple"tuple(number, string, bool)" (positional)
Ordered"ordered(a: number, b: string)" (named, order-sensitive)
Closure"|x: number| :string" (pipe-delimited params with colon-return)
Stream"stream(string):number" (chunk type and resolution type)
Bare stream (no constraints)"stream"
Bare list (no element type)"list"
Bare dict (no fields)"dict"
Bare tuple (no elements)"tuple"
Bare ordered (no fields)"ordered"
Bare closure (no params)"closure"

Stream Reflection

Streams expose two annotation properties: ^chunk and ^output. Both return a RillTypeValue.

PropertyReturnsDescription
^chunkRillTypeValueChunk type; any when unconstrained
^outputRillTypeValueResolution type; any when unconstrained
# Accessing ^chunk and ^output on a stream value (requires host-provided stream)
app::make_stream() => $s
$s.^chunk    # returns any (unconstrained stream)
$s.^output   # returns any (unconstrained stream)
# Typed stream: ^chunk and ^output reflect declared types
app::make_typed_stream() => $s   # chunk: string, output: number
$s.^chunk    # returns string type
$s.^output   # returns number type

Structural subtyping applies to both chunk and resolution types. A stream(string):number satisfies :stream(any):any and :stream.

Type Assertions

Use type assertions to validate values at runtime.

Assert Type (:type)

Error if type doesn’t match, returns value unchanged:

# Postfix form (binds tighter than method calls)
42:number                     # passes, returns 42
(1 + 2):number                # passes, returns 3
42:number -> :>string         # "42" - assertion then conversion

# Pipe target form
"hello" -> :string            # passes, returns "hello"
[a: 1, b: 2] => $val
$val -> :dict -> .keys        # assert dict, then get keys
"hello" -> :number            # Error: expected number, got string
# Parameterized type assertions
[1, 2, 3] -> :list(number)                          # passes, returns list[1, 2, 3]
[a: 1, b: "hello"] -> :dict(a: number, b: string)  # passes
["a", "b"] -> :list(number)            # ERROR: expected list(number), got list(string)

Trailing Defaults in Collection Type Assertions

: and :? accept values that omit trailing fields when those fields have defaults in the type constructor. This applies to dict, tuple, and ordered.

Assign the type constructor to a variable, then use the variable in assertion position:

# dict: value omits trailing defaulted field
dict(b: string, a: string = "a") => $dt
[b: "b"] -> :$dt
# Result: [b: "b"]
# dict check
dict(b: string, a: string = "a") => $dt
[b: "b"] -> :?$dt
# Result: true
# tuple: value shorter than type, trailing field has default
tuple(string, number = 0) => $tt
tuple["x"] -> :$tt
# Result: tuple["x"]
# ordered: value omits trailing defaulted field
ordered(x: number, y: number = 0) => $ot
ordered[x: 1] -> :$ot
# Result: ordered[x: 1]

The assertion passes and returns the original value unchanged. No field synthesis occurs. Use :> (convert) to fill missing fields with their defaults.

A missing field without a default causes the assertion to fail:

# Error: expected dict(b: string, a: string), missing required field 'a'
dict(b: string, a: string) => $dt
[b: "b"] -> :$dt

Check Type (:?type)

Returns boolean, no error:

# Postfix form
42:?number                    # true
"hello":?number               # false

# Pipe target form
"hello" -> :?string           # true
[1, 2, 3]:?list(number)               # true
["a", "b"]:?list(number)              # false

Type checks work in conditionals:

$val -> :?list ? process() ! skip()   # branch on type

Supported types: string, number, bool, closure, list, dict, ordered, tuple, vector, stream, any, type

Parameterized forms accept a type argument list: list(string), dict(a: number, b: string), tuple(number, string). The runtime deep-validates element types on match.

The vector type matches host-provided typed arrays. The any type name accepts any value type — useful for generic closures. The ordered type matches containers produced by *dict spread.

Both types are valid in closure parameter positions, capture type assertions, and type assertions:

# Closure parameter with vector type annotation
|x: vector| { $x } => $fn
app::embed("hello") => $v
$fn($v) -> .model
# Result: "mock-embed"
# Closure parameter with any type annotation
|x: any| { $x } => $fn
$fn("hello")
# Result: "hello"
# Type assertion: :vector and :any
app::embed("hello") => $v
$v -> :vector
# Result: vector(mock-embed, 3d)

$v -> :any
# Result: vector(mock-embed, 3d)
# Capture type assertion with vector type
app::embed("hello") => $x:vector
$x -> .model
# Result: "mock-embed"
# Capture type assertion with parameterized type
[1, 2] => $x:list(number)
$x[0]
# Result: 1

Stream Assertions

:stream asserts the value is a stream. :stream(T) additionally validates the chunk type. :stream(T):R validates both the chunk type and the resolution type. :?stream returns a boolean.

# :stream — assert value is a stream (requires host-provided stream)
app::make_stream() => $s
$s -> :stream
# :stream(T) — assert stream with specific chunk type
app::make_stream() => $s
$s -> :stream(string)
# :stream(T):R — assert stream with chunk and resolution types
app::make_stream() => $s
$s -> :stream(string):number
# :?stream — check type without halting
app::make_stream() => $s
$s -> :?stream
# Result: true

Attempting to convert a non-stream value to a stream with :>stream halts execution — there is no conversion path to the stream type [EC-20]:

# Error: RILL-R002: Cannot convert string to stream
"hello" -> :>stream

In Pipe Chains

# Assert typed list and continue processing
[1, 2, 3] -> :list(number) -> each { $ * 2 }

# Multiple assertions in chain
"test" -> :string -> .len -> :number   # 4

Use Cases

# Validate function input
|data| {
  $data -> :list              # assert input is list
  $data -> each { $ * 2 }
} => $process_items

# Type-safe branching
|val| {
  $val -> :?number ? ($val * 2) ! ($val -> .len)
} => $process
$process(5)        # 10
$process("hello")  # 5

Defaults in Type Expressions

Closure parameters accept an optional = literal default in the annotation position:

|name: type = literal| body

This default participates in structural type matching via :?. The rule is one-directional:

Value param has defaultType param has default:? result
YesNotrue (superset satisfies)
NoYesfalse (missing contract)
YesYestrue if defaults are equal
NoNotrue

A closure with defaults satisfies a type annotation without defaults, because the value provides more than the annotation requires. A closure without defaults fails an annotation that requires defaults, because it cannot fulfil the contract.

# A closure type without defaults (the annotation contract)
|x: string, y: number|{ $x } => $ref
$ref.^type => $refType

# A closure WITH defaults satisfies the annotation WITHOUT defaults
|x: string = "a", y: number = 0|{ $x } => $fn
$fn -> :?$refType
# Result: true

The reverse fails: a closure without defaults does not satisfy an annotation that declares defaults.

# A closure type WITH defaults (requires caller-omittable params)
|x: string = "a", y: number = 0|{ $x } => $ref
$ref.^type => $refType

# A closure WITHOUT defaults fails the annotation WITH defaults
|x: string, y: number|{ $x } => $fn
$fn -> :?$refType
# Result: false

See Type System: Defaults in Type Expressions and Host API Types for the structureMatches TypeScript API.

Union Types

A union type matches any one of two or more listed types. Use T1|T2 syntax wherever a type annotation is accepted.

Overview

Union types appear in type assertions, type checks, capture annotations, and destructure patterns. The | separator joins members into a union. At runtime, a union matches if the value satisfies any member.

# Union assertion: number satisfies string|number
42 -> :string|number
# Result: 42

Type Assertion

Assert that a value matches at least one union member. Execution halts if no member matches:

42 -> :string|number
# Result: 42
# Error: Type assertion failed: expected string|number, got bool
true -> :string|number

Type Check

Check whether a value matches a union without halting on failure:

42:?string|number
# Result: true
"hello":?string|number
# Result: true
true:?string|number
# Result: false

Capture Annotation

Annotate a capture variable with a union type. The runtime validates the assigned value against all union members:

"hello" => $x:string|number
$x
# Result: "hello"
# Error: Type mismatch: cannot assign bool to $x:string|number
true => $x:string|number

Parameterized Unions

Union members can be parameterized types. Structural validation applies to each member:

["a", "b"] -> :list(string)|dict
# Result: list["a", "b"]

Three-or-More Members

Chain additional members with |. The runtime checks each member left to right:

"hello" -> :string|number|bool
# Result: "hello"

Error Behavior

A type assertion fails when the value does not satisfy any member. The error message names the full union:

# Error: Type assertion failed: expected string|number, got bool
true -> :string|number

See Operators for union types in destructure and existence positions.

Type-Locked Variables

Variables lock type on first assignment. The type is inferred from the value or declared explicitly:

"hello" => $name              # implicit: locked as string
"world" => $name              # OK: same type
5 => $name                    # ERROR: cannot assign number to string
"hello" => $name:string       # explicit: declare and lock as string
42 => $count:number           # explicit: declare and lock as number

Inline Capture with Type

"hello" => $x:string -> .len  # type annotation in mid-chain

Type annotations validate on assignment and prevent accidental type changes:

|x|$x => $fn                  # locked as closure
"text" => $fn                 # ERROR: cannot assign string to closure

Type Values

rill has a runtime type named type. A type value represents a rill type — including full structural information for collection types.

.^type Operator

.^type returns the structural type value for any rill value:

42 => $n
$n.^type == number
# Result: true

"hello" => $s
$s.^type == string
# Result: true

[1, 2] => $l
$l.^type == list(number)
# Result: true

[a: 1] => $d
$d.^type == dict(a: number)
# Result: true
ordered[a: 1, b: 2] => $o
$o.^type.name
# Result: "ordered"

||{ $ } => $fn
$fn.^type == closure
# Result: true
app::embed("hello world") => $vec
$vec.^type == vector
# Result: true

Type Name Expressions

All type names are valid expressions that produce type values:

string => $st
$st.^type == type
# Result: true

number => $nt
$nt.^type == type
# Result: true

type => $tt
$tt.^type == type
# Result: true

.^type.name Property

Access the coarse type name via .^type.name on any value:

42 => $n
$n.^type.name
# Result: "number"
"hello" => $s
$s.^type.name
# Result: "string"
[1, 2] => $l
$l.^type.name
# Result: "list"

Dot-Notation Properties on Type Values

Type values expose two properties via dot notation:

PropertyReturn TypeDescription
.namestringCoarse type name ("number", "list", "dict", etc.)
.signaturestringFull structural type string
number => $t
$t.name
# Result: "number"
dict => $t
$t.name
# Result: "dict"

.signature returns the full structural representation via formatStructure:

list(number) => $t
$t.signature
# Result: "list(number)"
|y: string|($y):string => $fn
$fn.^type.signature
# Result: "|y: string| :string"

Combining .^type with .name and .signature gives both coarse and structural information:

42.^type.name
# Result: "number"
42.^type.signature
# Result: "number"

Unknown dot properties on type values raise RILL-R009:

number.unknownProp
# Error: RILL-R009: Unknown property 'unknownProp' on type value

.^name on a type value raises RILL-R008 (“Annotation access not supported on type values”). Use .name (dot notation) instead:

number.^name
# Error: RILL-R008: Annotation access not supported on type values

Type Value Equality

Type values compare with == and !=. Structural types compare structurally:

42 => $n
$n.^type == number
# Result: true
42 => $n
$n.^type == string
# Result: false
"hello" => $a
"world" => $b
$a.^type == $b.^type
# Result: true
[1, 2] => $l
$l.^type == list(number)
# Result: true
["a", "b"] => $strs
$strs.^type == list(number)
# Result: false

The type of a type value is type:

42 => $n
$n.^type => $tv
$tv.^type == type
# Result: true

type => $t
$t.^type == type
# Result: true

Built-in Method Signatures

The following table lists all built-in methods with their typed signatures. Methods marked “any (runtime checked)” accept any receiver but throw at runtime if the receiver type is wrong.

MethodReceiver TypesParamsReturn
.lenstring, list, dict(none)number
.trimstring(none)string
.headstring, list(none)any
.tailstring, list(none)any
.firstany (runtime checked)(none)iterator
.atany (runtime checked)index: numberany
.splitstringseparator: string = "\n"list
.joinlistseparator: string = ","string
.linesstring(none)list
.emptystring, list, dict, bool, number(none)bool
.starts_withstringprefix: stringbool
.ends_withstringsuffix: stringbool
.lowerstring(none)string
.upperstring(none)string
.replacestringpattern: string, replacement: stringstring
.replace_allstringpattern: string, replacement: stringstring
.containsstringsearch: stringbool
.matchstringpattern: stringdict
.is_matchstringpattern: stringbool
.index_ofstringsearch: stringnumber
.repeatstringcount: numberstring
.pad_startstringlength: number, fill: string = " "string
.pad_endstringlength: number, fill: string = " "string
.eqanyother: anybool
.neanyother: anybool
.ltnumber, stringother: anybool
.gtnumber, stringother: anybool
.lenumber, stringother: anybool
.genumber, stringother: anybool

bool supports only equality (==, !=). Ordering (<, >, <=, >=) on bool raises RILL-R002. | .keys | dict (runtime checked) | (none) | list | | .values | dict (runtime checked) | (none) | list | | .entries | dict (runtime checked) | (none) | list | | .has | list (runtime checked) | value: any | bool | | .has_any | list (runtime checked) | candidates: list | bool | | .has_all | list (runtime checked) | candidates: list | bool | | .dimensions | vector (runtime checked) | (none) | number | | .model | vector (runtime checked) | (none) | string | | .similarity | vector (runtime checked) | other: any | number | | .dot | vector (runtime checked) | other: any | number | | .distance | vector (runtime checked) | other: any | number | | .norm | vector (runtime checked) | (none) | number | | .normalize | vector (runtime checked) | (none) | any |


Global Utilities

FunctionDescription
jsonConvert to JSON string
[a: 1, b: 2] -> json
# Result: '{"a":1,"b":2}'

json closure handling:

  • Direct closure → error: |x|{ $x } -> json throws “Cannot serialize closure to JSON”
  • Closures in dicts → skipped: [a: 1, fn: ||{ 0 }] -> json returns '{"a":1}'
  • Closures in lists → skipped: [1, ||{ 0 }, 2] -> json returns '[1,2]'

See Also

DocumentDescription
TypesPrimitives, collections, and value types
VariablesDeclaration, scope, $ binding
ClosuresClosure semantics and patterns
OperatorsType assertions and existence checks in operators
ReferenceQuick reference tables