Lambda Type System

July 2, 2026 · View on GitHub

This document covers Lambda's type system, including first-class types, type hierarchy, type patterns, and string pattern definitions.

Related Documentation:


Table of Contents

  1. Type System Overview
  2. Type Hierarchy
  3. First-Class Types
  4. Basic Types
  5. Collection Types
  6. Function Types
  7. Type Declarations
  8. Type Occurrences
  9. Type Patterns
  10. String Patterns
  11. Type Checking
  12. Type Inference

Type System Overview

Lambda Script features a strong, static type system with inference. Types are:

  • First-class values — Types can be assigned to variables, passed as arguments, and returned from functions
  • Structurally typed — Compatibility based on structure, not declaration
  • Inferred — Types are automatically deduced when not explicitly annotated
  • Checked at compile-time — Type errors caught before execution

Design Principles

  1. Safety: Prevent runtime type errors through compile-time checking
  2. Expressiveness: Rich type constructs for complex data modeling
  3. Ergonomics: Type inference reduces annotation burden
  4. Documents as Data: Types model structured documents naturally

Type Hierarchy

Lambda's type system forms a hierarchy with any at the top and null at the bottom:

Type Hierarchy

Subtype Relations

SubtypeSupertypeExample
intnumber42 is numbertrue
floatnumber3.14 is numbertrue
i8 i16 i32 etc.number42i8 is numbertrue
rangecontainer(1 to 10) is rangetrue
int[]any[][1,2,3] is any[]true
nullT?null is int?true
every typeany"hello" is anytrue

First-Class Types

Types in Lambda are first-class values that can be manipulated like any other value:

// Assign types to variables
let T = int
let StringList = string[]
let UserType = {name: string, age: int}

// Pass types as arguments
fn validate(value, expected_type: type) => value is expected_type

validate(42, int)           // true
validate("hello", int)      // false

// Return types from functions
fn element_type(arr_type: type) => arr_type.element
element_type(int[])         // int

// Type in collections
let types = [int, string, bool]

Type Inspection

// Get type of a value
type(42)           // type int
type("hello")      // type string
type([1, 2, 3])    // type array
type({a: 1})       // type map

// Sized numeric types return specific type
type(42i8)         // type i8
type(255u16)       // type u16
type(3.14f32)      // type f32
type(1000u64)      // type u64

// Type comparison
type(42) == int           // true
type(123) != string       // true
type([1,2]) == array      // true

Basic Types

Primitive Type Literals

// Type literals
null        // Null type (singleton)
bool        // Boolean type
int         // 56-bit signed integer
float       // 64-bit floating point
decimal     // Arbitrary precision decimal
string      // UTF-8 string
symbol      // Interned symbol
binary      // Binary data
datetime    // Date and time
range       // Integer range (e.g. 1 to 10)
path        // File path or URL

// Sized numeric type literals
i8  i16  i32  i64    // Sized signed integers
u8  u16  u32  u64    // Sized unsigned integers
f16  f32  f64        // Sized floats

Type Constants

// Special type values
any         // Top type (supertype of all)
error       // Error type
number      // Union: int | float | sized numerics

Type Examples

// Variables with type annotations
let x: int = 42
let name: string = "Alice"
let pi: float = 3.14159
let active: bool = true
let created: datetime = t'2025-01-01'
let config_path: path = .config.json

// Sized numeric type annotations
let a: i8 = 42i8
let b: u32 = 255u32
let c: f32 = 3.14f32
let d: u64 = 1000u64

Sized Numeric Type Checking

The is operator checks for the exact sized type. All sized numerics also match number:

42i8 is i8           // true
42i8 is u8           // false (different signedness)
42i8 is i16          // false (different width)
42i8 is number       // true  (all numerics match number)
3.14f32 is f32       // true
3.14f32 is f16       // false
1000u64 is u64       // true
1000u64 is number    // true

Collection Types

Range Types

Ranges represent a contiguous sequence of integer values with inclusive start and end bounds.

// Range type keyword
range              // Any range value

// Range literal type (specific bounds)
1 to 10            // Range from 1 to 10 inclusive
0 to 255           // Byte range
-100 to 100        // Negative to positive

Range Type in Annotations

// As a parameter type
fn sum_range(r: range) => ...

// Type checking
(1 to 10) is range          // true
42 is range                 // false

// Range literal types in match expressions
fn grade(score: int) => match score {
    case 90 to 100: "A"
    case 80 to 89: "B"
    case 70 to 79: "C"
    case 60 to 69: "D"
    default: "F"
}

// Or-patterns with ranges
fn classify(code: int) => match code {
    case 200 to 299: "success"
    case 400 to 499 | 500 to 599: "error"
    default: "other"
}

Range Containment

The in operator tests whether a value falls within a range:

5 in 1 to 10       // true
15 in 1 to 10      // false

Range Iteration

Ranges are iterable and can be used in for expressions:

for i in 1 to 5 { print(i) }   // 1 2 3 4 5
let squares = for (i in 1 to 5) i ^ 2   // [1, 4, 9, 16, 25]

Array Types

Lambda has two forms for array types:

Form 1: Bracket notation — a type with an occurrence modifier inside [ ]:

[int*]             // Array of zero or more ints
[int+]             // Array of one or more ints (non-empty)
[string*]          // Array of zero or more strings
[bool+]            // Non-empty array of booleans

Note: [int] (without * or +) means a tuple of exactly 1 int, not an array of ints.

Form 2: Occurrence suffix — a type followed by [] or [n]:

int[]              // Array of zero or more ints (same as [int*])
string[]           // Array of zero or more strings
float[]            // Array of zero or more floats
int[5]             // Array of exactly 5 ints
int[3+]            // Array of 3 or more ints
int[2, 10]         // Array of 2 to 10 ints

Nested arrays:

int[][]            // Array of int arrays
string[][]         // 2D array of strings

Examples:

let nums: int[] = [1, 2, 3]
let matrix: int[][] = [[1, 2], [3, 4]]
let names: [string+] = ["Alice", "Bob"]

Map Types

// Structural map types
{name: string, age: int}           // Required fields
{name: string, age?: int}          // Optional age field
{name: string, ...}                // Open map (allows extra fields)

// Nested maps
{
    user: {name: string, email: string},
    settings: {theme: string, notifications: bool}
}

// Examples
let person: {name: string, age: int} = {name: "Bob", age: 25}

Element Types

// Element type syntax
<tag>                              // Element with tag
<tag attr: type>                   // With attribute types
<tag attr: type; content_type>     // With content type

// Examples
type Paragraph = <p; string>
type Link = <a href: string; string>
type Article = <article title: string, author: string;
    string,           // Text content
    Section*          // Zero or more sections
>

Function Types

Function Type Syntax

// Function type declaration
fn (int) int                    // Takes int, returns int
fn (int, int) int               // Takes two ints, returns int
fn (string, bool) string        // Takes string and bool, returns string
fn int                          // No params, returns int (shorthand for fn () int)
fn ()                           // No params, no meaningful return

// With parameter names (documentation only)
fn (a: int, b: int) int         // Named parameters
fn (name: string) string        // Named parameter

// Higher-order function types
fn (fn (int) int) int           // Takes a function, returns int
fn (int) fn (int) int           // Returns a function

Function Type Examples

// Type alias for function types
type BinaryOp = fn (a: int, b: int) int
type Predicate = fn (x: int) bool
type Transform = fn (s: string) string

// Using function types
let add: BinaryOp = (a, b) => a + b
let isPositive: Predicate = (x) => x > 0
let upper: Transform = (s) => s.upper()

// Higher-order function
fn apply(f: fn (int) int, x: int) int => f(x)
apply((x) => x * 2, 5)  // 10

Type Declarations

Type Aliases

// Simple aliases
type UserId = int
type UserName = string
type Point = (float, float)

// Collection aliases
type IntList = int[]
type StringMap = {string: string}

// Usage
let id: UserId = 12345
let name: UserName = "alice"
let pos: Point = (10.5, 20.5)

Object Types

Object types are nominally-typed maps with optional methods, inheritance, default values, and constraints. Unlike map type aliases (type T = {...}), object types create a distinct runtime type checked by name.

// Object type with fields and methods
type Point {
    x: float, y: float;
    fn distance(other: Point) => math.sqrt((x - other.x)**2 + (y - other.y)**2)
    fn magnitude() => math.sqrt(x**2 + y**2)
}

// Inheritance
type Circle : Point {
    radius: float;
    fn area() => 3.14159 * radius ** 2
}

// Default values
type Counter {
    value: int = 0;
    fn double() => value * 2
    pn increment() { value = value + 1 }   // Mutation method
}

// Field and object constraints
type User {
    name: string that (len(~) > 0),
    age: int that (0 <= ~ and ~ <= 150);
    that (~.name != "admin")               // Object-level constraint
}

// In 'that' clauses, bare identifiers resolve to ~.name implicitly:
type User2 {
    name: string that (len(~) > 0),        // ~ needed for scalar field value
    age: int that (~ > 0);
    that (name != "admin")                  // 'name' resolves to ~.name
}

// Object literals
let p = <Point x: 3.0, y: 4.0>
let c = <Counter>                          // All defaults
let c2 = <Circle x: 0.0, y: 0.0, radius: 5.0>

// Type checking (nominal only)
p is Point     // true
p is object    // true
p is map       // true (objects are map-compatible)
{x: 1.0, y: 2.0} is Point  // false (plain maps don't match)

// Object update (copy with overrides)
let p2 = <Point *:p, x: 10.0>   // copy p, override x

Type Occurrences

Type occurrences specify cardinality and optionality:

Optional Types

// Optional (nullable) types
int?               // int | null
string?            // string | null
int[]?             // Array or null

// In function parameters
fn greet(name: string, title?: string) => ...

// In map fields
type User = {
    name: string,      // Required
    nickname?: string  // Optional
}

Type Occurrence Modifiers

// Zero or more (array)
int*               // Same as int[] — array of zero or more
string*            // Array of zero or more strings

// One or more (non-empty array)
int+               // Array of at least one int
string+            // Non-empty string array

// Occurrence suffix forms
int[]              // Array of zero or more ints (same as int*)
float[]            // Array of zero or more floats
int[5]             // Array of exactly 5 ints
int[3+]            // Array of 3 or more ints
int[2, 10]         // Array of 2 to 10 ints

// Examples
type Args = string*        // Zero or more arguments
type Names = string+       // At least one name required

// In variables and parameters
var positions: float[] = [0.0, 1.0, 2.0]
pn update(arr: int[], n: int) { arr[0] = n }

// In function signatures
fn concat(parts: string+) => ...   // Requires at least one

Occurrence Summary

SyntaxMeaningEquivalent
TExactly oneRequired
T?Zero or oneT | null
T*Zero or moreT[]
T+One or moreNon-empty T[]
T[]Zero or moreSame as T*
T[n]Exactly nFixed-size array
T[n+]n or moreMin-size array
T[n, m]n to mBounded-size array

Type Patterns

Type patterns enable matching and destructuring based on type structure.

Basic Type Matching

// Type check with 'is'
42 is int                  // true
"hello" is string          // true
[1, 2] is int[]            // true

// Negated type check with '!'
!(42 is string)            // true
!(null is int)             // true
!("hello" is int)          // true

// Type equality
type(42) == int            // true
type("hi") == string       // true
type([1,2]) == array       // true
type(42) != string         // true

// Type in conditionals
if (value is string) {
    value.upper()          // Safe: value is string here
}

Collection Type Patterns

// Array element type matching
let arr = [1, 2, 3]
arr is int[]               // true
arr is string[]            // false
arr is number[]            // true (int is subtype of number)

// Map structure matching
let obj = {name: "Alice", age: 30}
obj is {name: string}      // true (has required field)
obj is {name: string, age: int}  // true
obj is {email: string}     // false (missing required field)

Union Type Patterns

The union operator | combines types so a value can be one of several types:

// Basic union
int | string           // Either int or string
int | float | string   // One of three types

// Nullable types (shorthand for union with null)
int?                   // Same as: int | null
string?                // Same as: string | null

// Union in function parameters
fn process(value: int | string) => ...

// Union in collections
let mixed: (int | string)[] = [1, "two", 3, "four"]

Pattern matching with union types:

fn describe(value: int | string | bool) => {
    if (value is int) "integer: " ++ string(value)
    else if (value is string) "string: " ++ value
    else "boolean: " ++ string(value)
}

fn process(value: int | string | null) => {
    if (value is null) "nothing"
    else if (value is int) "number: " ++ string(value)
    else "text: " ++ value
}

Exclusion Type Patterns

The exclusion operator ! subtracts one type from another — T1 ! T2 matches values that match T1 but not T2:

// any except null (non-nullable any)
any ! null

// number but not float (only int)
number ! float

// scalar but not bool
scalar ! bool

Exclusion is useful for narrowing broad types:

// Accept any non-null value
fn required(value: any ! null) => value

// Accept any number except float
fn integers_only(n: number ! float) => n * 2

// Collection of non-null values
type NonNullList = [any ! null]

Exclusion can also be used in is checks and match expressions:

// Type check
42 is (any ! null)         // true
null is (any ! null)       // false
42 is (number ! float)     // true (int matches)
3.14 is (number ! float)   // false (float excluded)

// In match expressions
fn classify(x) => match x {
    case number ! float: "integer"
    case float: "float"
    case string: "text"
    default: "other"
}

Negation Types

The prefix ! operator negates a type — !T matches any value that does not match T:

// Not null — any non-null value
!null

// Not string — anything except string
!string

// Not bool — anything except bool
!bool

Negation differs from exclusion in that it has no base type — !T is equivalent to any ! T:

SyntaxMeaningEquivalent
!TAnything that is not Tany ! T
T1 ! T2Values matching T1 but not T2(no shorthand)

Negation is useful in type annotations and pattern matching:

// Parameter that rejects null
fn required(value: !null) => value

// Type negation works in expressions with 'is'
42 is !null          // true (int is not null)
null is !null        // false
"hi" is !int         // true (string is not int)
42 is !int           // false

Note: !T creates a negation type value. Use x is !T to check that x does not match type T. For logical negation, use not (e.g., not true).

In string patterns, ! negates character classes:

// Any character except a digit
string NotDigit = !\d

// Any character except whitespace
string NotSpace = !\s

Constrained Types (that)

The that clause attaches a runtime constraint to a type. A value matches T that (predicate) only if it matches T and the predicate evaluates to true, with ~ referring to the value being checked.

// Syntax: type that (predicate using ~)
int that (~ > 0)                    // Positive integer
int that (5 < ~ < 10)              // Integer between 5 and 10 (exclusive)
string that (len(~) > 0)           // Non-empty string

Lambda normalizes "" and '' to null, so user-data string and symbol values should be treated as non-empty. A non-empty string constraint is still useful as an explicit validation rule, and it rejects null.

Type Aliases with Constraints

Name constrained types for reuse:

type Positive = int that (~ > 0)
type Percentage = int that (0 <= ~ and ~ <= 100)
type NonEmpty = string that (len(~) > 0)
type Between5And10 = int that (5 < ~ < 10)

// Type checking
1 is Positive          // true
-1 is Positive         // false
50 is Percentage       // true
110 is Percentage      // false
"hi" is NonEmpty       // true
"" is NonEmpty         // false ("" is null, not string)
7 is Between5And10     // true
5 is Between5And10     // false (not > 5)

Constrained Types in match

Constrained types work as match arms for precise value-based dispatch:

fn classify(x) => match x {
    case int that (~ > 0): "positive"
    case int that (~ < 0): "negative"
    case 0: "zero"
    default: "other"
}

fn grade(score) => match score {
    case int that (90 <= ~ <= 100): "A"
    case int that (80 <= ~ < 90): "B"
    case int that (70 <= ~ < 80): "C"
    case int that (60 <= ~ < 70): "D"
    case int that (0 <= ~ < 60): "F"
    default: "invalid"
}

Field-Level Constraints in Object Types

Object type fields can each carry their own that constraint:

type User {
    name: string that (len(~) > 0),
    age: int that (0 <= ~ and ~ <= 150),
    email: string;
}

<User name: "Alice", age: 30, email: "a@x.com"> is User   // true
<User name: "", age: 30, email: "a@x.com"> is User         // false (empty name)
<User name: "Bob", age: -5, email: "b@x.com"> is User      // false (negative age)

Object-Level Constraints

A that clause after the semicolon constrains the entire object, with ~ referring to the object itself:

type DateRange {
    start: int,
    end: int;
    that (~.end > ~.start)
}

<DateRange start: 1, end: 10> is DateRange    // true
<DateRange start: 10, end: 1> is DateRange    // false

Field-level and object-level constraints can be combined:

type Config {
    min: int that (~ >= 0),
    max: int that (~ >= 0);
    that (~.max > ~.min)
}

In object-level that clauses, bare identifiers that are not in scope resolve to ~.name implicitly:

type User2 {
    name: string that (len(~) > 0),     // ~ = field value (scalar)
    age: int that (~ > 0);
    that (name != "admin")               // name resolves to ~.name
}

String Patterns

String patterns define named validation rules for string and symbol values, using a regex-like syntax integrated into the type system.

Pattern Definition Syntax

// String pattern: defines a pattern type for strings
string PatternName = pattern_expression

// Symbol pattern: defines a pattern type for symbols
symbol PatternName = pattern_expression

Literal Patterns

// Exact string match
string Hello = "hello"

// Alternatives with union operator
string Greeting = "hello" | "hi" | "hey"

// HTTP methods
string HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH"

Character Classes

// Built-in character classes
\d    // digit [0-9]
\w    // word character [a-zA-Z0-9_]
\s    // whitespace
\a    // alphabetic [a-zA-Z]

// Any single character
\.

// Any characters (zero or more) - shorthand for \.*
...

// Examples
string Digit = \d                    // single digit
string Word = \w+                    // one or more word characters
string Anything = ...                // any string

Character Ranges

// Range with 'to' keyword (like regex [a-z])
string LowerLetter = "a" to "z"
string UpperLetter = "A" to "Z"
string HexDigit = "0" to "9" | "a" to "f" | "A" to "F"

Occurrence Modifiers

// Standard quantifiers
?       // zero or one (optional)
+       // one or more
*       // zero or more

// Exact count
[n]     // exactly n occurrences

// Bounded ranges
[n+]    // n or more occurrences
[n, m]  // between n and m occurrences (inclusive)

// Examples
string OptionalPrefix = "pre"? \w+           // optional "pre" prefix
string Identifier = \a \w*                    // letter followed by word chars
string ThreeDigits = \d[3]                    // exactly 3 digits
string Phone = \d[3] "-" \d[3] "-" \d[4]      // 555-123-4567
string ZipCode = \d[5] ("-" \d[4])?           // 12345 or 12345-6789

Pattern Composition

// Sequence: patterns concatenate
string FullName = \a+ " " \a+                 // first space last

// Union: match either pattern
string YesNo = "yes" | "no"

// Intersection: must match both patterns
string AlphaNum = \a & \w                     // alpha that is also word char

// Negation: exclude pattern
string NotDigit = !\d                         // any char except digit

Complex Pattern Examples

// Email-like pattern
string Email = \w+ "@" \w+ "." \a[2, 6]

// URL path segment
string PathSegment = ("/" \w+)+

// Version string: v1.2.3
string Version = "v" \d+ "." \d+ "." \d+

// Hex color: #RGB or #RRGGBB
string HexDigit = "0" to "9" | "a" to "f" | "A" to "F"
string HexColor = "#" (HexDigit[3] | HexDigit[6])

// Date format: YYYY-MM-DD
string DatePattern = \d[4] "-" \d[2] "-" \d[2]

// Username: 3-20 chars, starts with letter
string Username = \a \w[2, 19]

Symbol Patterns

Symbol patterns work identically but define patterns for symbol values:

// Symbol pattern for identifiers
symbol Keyword = 'if' | 'else' | 'for' | 'while'

Using Patterns as Types

Pattern names can be used as types for validation:

// Use pattern as parameter type
fn validate_email(email: Email) => ...

// Use in type annotations
let method: HttpMethod = "GET"

// Type checking with 'is' (full-match semantics)
"hello" is Greeting              // true
"goodbye" is Greeting            // false
"v1.2.3" is Version              // true

Pattern Matching with match

Named string patterns can be used as match arms. Each arm uses full-match semantics — the entire string must match the pattern:

string digits = \d+
string alpha = \a+

fn classify(s) => match s {
    case digits: "number"         // "123" → "number"
    case alpha: "word"            // "hello" → "word"
    default: "other"              // "hello world" → "other"
}

// Mix literal and pattern arms
string num = \d+
fn tag(s) => match s {
    case "hello": "greeting"      // literal match checked first
    case num: "number"
    default: "unknown"
}

Pattern-Aware String Functions

Named patterns can be passed to find(), replace(), and split() as the match argument. These functions use partial/search semantics (find matches within the string), unlike is and match which require full-string matches.

string digits = \d+
string ws = \s+

// find(str, pattern) → [{value, index}, ...]
find("a1b22c333", digits)
// [{value: "1", index: 1}, {value: "22", index: 3}, {value: "333", index: 6}]

// replace(str, pattern, replacement) → str
replace("a1b2c3", digits, "N")        // "aNbNcN"
replace("hello   world", ws, " ")     // "hello world"

// split(str, pattern) → [str, ...]
split("a1b2c3", digits)               // ["a", "b", "c", ""]
split("a1b2c3", digits, true)         // ["a", "1", "b", "2", "c", "3", ""]  — keep delimiters

All three functions also accept plain strings as the match argument (see Lambda_Sys_Func.md § String Functions).


Type Checking

Static Type Checking

Lambda performs type checking at compile time:

// Type errors caught at compile time
let x: int = "hello"           // Error: string not assignable to int
let y: int[] = [1, "two", 3]   // Error: mixed types in int[]

fn add(a: int, b: int) int => a + b
add(1, "2")                    // Error: string not assignable to int

Runtime Type Checks

Use is for runtime type validation:

fn safe_process(value: any) => {
    if (value is int) {
        value * 2
    } else if (value is string) {
        len(value)
    } else {
        error("Unsupported type: " ++ string(type(value)))
    }
}

Type Assertions

// Assert type (unsafe - runtime error if wrong)
let num = value as int         // Asserts value is int

// Safe assertion with check
let num = if (value is int) value else error("Expected int")

Type Inference

Lambda infers types automatically when not explicitly annotated:

Variable Inference

// Types inferred from initializer
let x = 42                     // x: int
let name = "Alice"             // name: string
let items = [1, 2, 3]          // items: int[]
let user = {name: "Bob"}       // user: {name: string}

Function Return Inference

// Return type inferred from body
fn double(x: int) => x * 2     // Returns int
fn greet(name: string) => "Hello, " ++ name  // Returns string

// Complex inference
fn process(items: int[]) => {
    let filtered = items where ~ > 0
    let doubled = filtered | ~ * 2
    sum(doubled)
}  // Returns int

Collection Inference

// Element type inferred from contents
let nums = [1, 2, 3]           // int[]
let mixed = [1, 2.5, 3]        // number[] (int promoted to number)
let empty = []                 // any[] (unknown element type)

// Map type inferred from structure
let config = {
    host: "localhost",
    port: 8080,
    debug: true
}  // {host: string, port: int, debug: bool}

Inference Limitations

// Sometimes explicit annotation needed
let empty: int[] = []          // Disambiguate empty array type

// Recursive types need annotation
type Node = {value: int, next: Node?}

// Complex generics may need hints
fn identity<T>(x: T) T => x    // Generic requires annotation

Type Compatibility

Structural Compatibility

Lambda uses structural typing — types are compatible if they have compatible structure:

type Point2D = {x: int, y: int}
type Coordinate = {x: int, y: int}

// These are compatible (same structure)
let p: Point2D = {x: 1, y: 2}
let c: Coordinate = p          // OK: same structure

// Subtype compatibility (more fields is OK)
type Point3D = {x: int, y: int, z: int}
let p3d: Point3D = {x: 1, y: 2, z: 3}
let p2d: Point2D = p3d         // OK: Point3D has all Point2D fields

This document covers Lambda's type system comprehensively. For data structure details, see Lambda Data. For expressions using these types, see Lambda Expressions.