Skip to content

Type System

Endo uses type inference to automatically deduce types. You can optionally add type annotations for documentation, disambiguation, or to catch errors earlier.

3.1 Primitive Types

Type Description Examples
int 64-bit signed integer 42, -17, 0xFF
float 64-bit floating point 3.14, -0.5, 1e10
str UTF-8 string "hello", 'literal'
bool Boolean true, false
unit No value (like void) ()

3.2 Compound Types

# Lists (homogeneous, variable length)
list<int>           # [1; 2; 3]
list<str>           # ["a"; "b"; "c"]

# Tuples (heterogeneous, fixed size)
(int, str)          # (42, "hello")
(int, str, bool)    # (1, "x", true)

# Option (represents presence or absence of a value)
option<int>         # Some 42 or None
option<str>         # Some "value" or None

# Result (represents success or failure)
result<int, str>    # Ok 42 or Error "failed"
result<str, Error>  # Ok "data" or Error { code = 1; message = "..." }

# Lazy (deferred computation with memoization)
lazy<int>           # lazy 42
lazy<str>           # lazy (computeString data)

3.3 Type Inference Examples

# Types are inferred automatically
let x = 42                    # x: int
let name = "Alice"            # name: str
let items = [1; 2; 3]         # items: list<int>
let pair = (1, "hello")       # pair: (int, str)
let double = fun x -> x * 2   # double: int -> int

# Inference through usage
let add x y = x + y           # add: int -> int -> int (inferred from +)
let greet name = $"Hi, {name}"  # greet: str -> str

# Explicit annotations when needed
let count: int = 42
let ratio: float = 42.0       # Would be int without annotation
let empty: list<str> = []     # Empty list needs type hint

# Function annotations
let add (x: int) (y: int): int = x + y
let parse (s: str): result<int, str> = tryParseInt s

3.4 Records

Records are named collections of fields. They provide structured data with named access.

# Define a record type
type Person = {
    name: str
    age: int
    email: option<str>
}

# Create record instances
let alice = { name = "Alice"; age = 30; email = Some "alice@example.com" }
let bob = { name = "Bob"; age = 25; email = None }

# Access fields
let aliceName = alice.name            # "Alice"
let bobAge = bob.age                  # 25

# Copy with update (functional update)
let olderAlice = { alice with age = 31 }
let bobWithEmail = { bob with email = Some "bob@example.com" }

# Nested records
type Address = {
    city: str
    country: str
}

type Employee = {
    person: Person
    address: Address
    salary: int
}

let emp = {
    person = alice
    address = { city = "NYC"; country = "USA" }
    salary = 100000
}

# Nested access
let city = emp.address.city           # "NYC"

# Nested update
let relocated = { emp with address = { emp.address with city = "Boston" } }

3.5 Discriminated Unions (Algebraic Data Types)

Unions represent values that can be one of several named cases, optionally with associated data.

# Define a union type
type Shape =
    | Circle of radius: float
    | Rectangle of width: float * height: float
    | Point

# Create instances
let c = Circle 5.0
let r = Rectangle (10.0, 20.0)
let p = Point

# Pattern match on unions
let area shape =
    match shape with
    | Circle r -> 3.14159 * r * r
    | Rectangle (w, h) -> w * h
    | Point -> 0.0

# Access named fields directly with dot notation
c.radius         # 5.0
r.width          # 10.0

# More complex union
type JsonValue =
    | JsonNull
    | JsonBool of bool
    | JsonNumber of float
    | JsonString of str
    | JsonArray of list<JsonValue>
    | JsonObject of list<(str, JsonValue)>

# Built-in unions (defined by the language)
# type option<T> = Some of T | None
# type result<T, E> = Ok of T | Error of E

3.6 Generic Types

User-defined record and union types can be parameterized with type variables, enabling reusable data structures.

Type parameters are declared with the 'name syntax in angle brackets after the type name:

# Generic union type
type Box<'a> =
    | Wrap of 'a
    | Empty

let b1 = Wrap 42       # Box<int>
let b2 = Wrap "hello"  # Box<str>

match b1 with
| Wrap x -> print x    # 42
| Empty -> print "empty"

# Multi-parameter generic union
type Either<'a, 'b> =
    | Left of 'a
    | Right of 'b

let e = Left 42
match e with
| Left x -> print x
| Right _ -> print "other"

# Generic record type
type Pair<'a, 'b> = { first: 'a; second: 'b }

let p = { first = 1; second = "hello" }
print p.first    # 1

# Recursive generic type (binary tree)
type Tree<'a> =
    | Leaf of 'a
    | Node of Tree<'a> * Tree<'a>

let t = Node (Leaf 1, Node (Leaf 2, Leaf 3))

Type erasure: All instantiations of a generic type share a single runtime type ID. Type parameters exist only at the type-checking level and are erased before code generation. This matches how built-in types like option<T> and result<T, E> work.

Type inference: Constructor usage infers type parameters automatically — no explicit type application needed at call sites.

3.7 Dot Property Access

Built-in types expose convenient dot properties for common queries.

# Tuple element access (by name or numeric index)
let pair = (42, "hello")
print (pair.fst)                      # 42
print (pair.0)                        # 42 (same as .fst)
print (pair.snd)                      # hello
print (pair.1)                        # hello (same as .snd)

let triple = (1, 2, 3)
print (triple.fst)                    # 1
print (triple.0)                      # 1 (same as .fst)
print (triple.snd)                    # 2
print (triple.1)                      # 2 (same as .snd)
print (triple.trd)                    # 3
print (triple.2)                      # 3 (same as .trd)

# Option properties
let opt = Some 42
print opt.isSome                      # true
print opt.isNone                      # false

# Result properties
let ok = Ok 100
print ok.isOk                         # true
print ok.isError                      # false

# String properties
let s = "hello"
print s.length                        # 5

3.8 Built-in Record Types

Endo provides several built-in record types for shell data. These are returned by built-in commands and support dot access, pattern matching, and pipeline operations.

Size

Represents byte quantities with human-readable display.

Field Type Description
bytes int Raw byte count

Constructors: Size.fromBytes, Size.fromKB, Size.fromMB, Size.fromGB, Size.fromTB

Literals: 42B, 1KB, 5MB, 2GB, 1TB, 3.5KB

DateTime

Represents a point in time (UTC).

Field Type Description
year int Year (e.g. 2024)
month int Month (1--12)
day int Day of month (1--31)
hour int Hour (0--23)
minute int Minute (0--59)
second int Second (0--59)
epoch int Unix epoch timestamp

Constructors: DateTime.now, DateTime.fromEpoch

TimeSpan

Represents a duration of time with millisecond precision.

Field Type Description
milliseconds int Duration in milliseconds

Constructors: TimeSpan.fromMilliseconds, TimeSpan.fromSeconds, TimeSpan.fromMinutes, TimeSpan.fromHours, TimeSpan.fromDays

Literals: 100ms, 5s, 2min, 1h, 1.5h

FileInfo

Returned by ls. Represents a file or directory entry.

Field Type Description
name str File name
size Size File size
mode FileMode Unix file permissions
mtime DateTime Last modification time
isDir bool Whether entry is a directory

FileMode

Represents Unix file permissions with human-readable display.

Field Type Description
bits int Raw permission bits

Computed properties: isReadable : bool, isWritable : bool, isExecutable : bool, owner : int (0-7), group : int (0-7), other : int (0-7).

Constructor: FileMode.fromBits n

ProcessInfo

Returned by ps. Represents a running process.

Field Type Description
pid int Process ID
ppid int Parent process ID
user str Owning user
cpu float CPU usage percentage
mem float Memory usage percentage
command str Command name

JobInfo

Returned by jobs. Represents a background job.

Field Type Description
id int Job number
state str Job state
command str Command string
pid int Process ID

See also: Lexical Elements | Variables & Bindings | Pattern Matching