Types

Hot is gradually typed. Add annotations where they help catch errors and document intent; skip them where they add noise. Hot infers types when it has enough information and uses Any when a value's type or shape cannot be established statically.

The model is deliberately hybrid:

  • Plain records are checked structurally when their fields are known.
  • Constructed types and enum variants retain nominal runtime tags.
  • Literal and union types narrow the accepted values.
  • Any is the dynamic escape hatch at partially typed boundaries.

Built-in Types

TypeDescriptionExample
StrText strings"hello", `template`, """block""", ```block template```
IntIntegers42
DecDecimal numbers19.99
BoolBooleanstrue, false
NullNull valuenull
VecVectors (also known as arrays)[1, 2, 3]
MapMaps (objects){a: 1}
FnFunctions(x) { x }
AnyAny typeanything
BytesBinary data—

Numbers

An Int is a 64-bit integer, from -9223372036854775808 to 9223372036854775807. Arithmetic whose result doesn't fit in an Int returns Err("Integer overflow"), as dividing by zero returns Err("Division by zero"). A Dec is an exact 256-bit decimal, so add(0.1, 0.2) is 0.3. Mixing an Int and a Dec gives a Dec.

Type Annotations

Add types to variables:

name: Str "Alice"
count: Int 42
prices: Vec<Dec> [9.99, 19.99]

Add types to function parameters and returns:

greet-typed fn (name: Str): Str {
  `Hello, ${name}!`
}

process fn (items: Vec<Int>, multiplier: Int): Vec<Int> {
  map(items, (x) { mul(x, multiplier) })
}
greet-typed("World")        → "Hello, World!"
process([1, 2, 3], 2)       → [2, 4, 6]

Types are Optional

You can skip types entirely:

// No types - still valid Hot
name-simple "Alice"
greet-simple fn (name) { `Hello, ${name}!` }

Hot will infer types where possible and allow Any elsewhere.

This is best-effort static checking rather than whole-program proof. A known record can be checked against a required field shape, while an untyped Map<Any, Any> may be accepted and fail later when code accesses a missing field. Add annotations at API, storage, event, and other trust boundaries when you want stronger diagnostics.

Generic Types

Parameterize collection types:

names-generic: Vec<Str> ["Alice", "Bob"]
counts-generic: Map<Str, Int> {apples: 5, oranges: 3}
matrix-generic: Vec<Vec<Int>> [[1, 2], [3, 4]]

Union Types

A value can be one of several types:

parse-number fn (input: Str): Int | Dec | Null {
  // Returns Int, Dec, or null
}

process fn (value: Str | Int): Str {
  Str(value)
}

Literal Unions

Union types can include literal values:

// String literals
Fruit type "apple" | "banana" | "orange"

// Number literals
DiceRoll type 1 | 2 | 3 | 4 | 5 | 6

// Mixed literals
Status type "pending" | "active" | 0 | 1

// Use in functions
pick-fruit fn (fruit: Fruit): Str {
  `You picked a ${fruit}!`
}

apple: Fruit "apple"   // Valid
pick-fruit("banana")   // Valid
pick-fruit("grape")    // constraint-violation: not a Fruit

Literal unions let you restrict values to a specific set of allowed values. A literal outside a closed union, as in pick-fruit("grape"), is a compile-time constraint-violation error from hot check. A closed union is checked at runtime too: a computed value from outside the set, such as one parsed from JSON, fails where it enters the type with Err(ConstraintError.Violation). An open union also accepts unknown values of the same type. Literal union values stay plain values, with no type tag. See Constraints.

They also work inline, without a named type:

set-speed fn (mode: "fast" | "slow", retries: 0 | 1 | 3, note: Str | null): Str {
  `Running ${mode} with ${retries} retries`
}

set-speed("fast", 3, null)   // "Running fast with 3 retries"

Name the type when you reuse it or extend it with open.

Constraints

Any type can carry a where clause, a predicate its values must pass:

Port type Int where between(%, 1, 65535)

listen fn (port: Port): Str {
  `listening on ${port}`
}

A literal argument that fails, such as listen(70000), is a compile-time constraint-violation error from hot check. A computed value that fails becomes a Result.Err holding a ConstraintError at run time. See Constraints for fields, invariants, preconditions, and how checks run.

Nullable Types

Use ? for values that might be null:

find-user fn (id: Str): User? {
  // Returns User or null
}

greet fn (name: Str, title: Str?): Str {
  // title can be a Str or null
}

Str? is shorthand for Str | Null.

Note: The ? syntax indicates the type accepts null—it does not make the parameter omittable. You must still pass an argument (either a value or null). For truly optional parameters, use function overloading.

Why no Option type? Languages without null (like Rust) use Option<T> with Some(value) and None to represent optional values. Hot has null to align with JavaScript/JSON data types, so T? achieves the same thing more concisely. While Option can technically express one extra state (Some(null) vs None), this distinction is rarely needed in practice and adds complexity for everyone. If an Option type is needed, you can define a custom type and make it core to your codebase.

Defining Custom Types

Struct Types

Define a type with fields:

Person type {
  name: Str,
  age: Int,
  email: Str?
}

Types Are Constructors

The type name is also its constructor function:

Point type {
  x: Int,
  y: Int
}

// Create an instance - type name IS the constructor
alice-point Point({x: 10, y: 20})
alice-point.x → 10
alice-point.y → 20

Key Concept: In Hot, types are functions that return values of that type. When you define a type, you're also defining a constructor function with the same name. This unifies type definitions and value creation into a single concept.

Named Arguments

A record type's constructor takes its fields by name, or one Map:

Account type {
  owner: Str,
  plan: Str,
  seats: Int?
}

// Name each field. A field that allows null can be left out.
team-account Account(owner: "Ada", plan: "team")

// The same value, built from a Map
map-account Account({owner: "Ada", plan: "team"})
team-account.owner          → "Ada"
team-account.seats          → null
eq(team-account, map-account) → true
  • Name every field you pass, in any order. Positional arguments, ... spreads, and pipes can't be mixed in.
  • A field whose type allows null can be left out. Every other field is required.
  • The compiler checks the names. A name that isn't a field or a missing required field is a named-argument error, and a name given twice doesn't parse. The Map form checks required fields when it runs.
  • A % in a field typed Fn becomes a lambda there.
  • Enum variants with a record payload take names too: Shape.Circle(radius: 5.0).
  • On a type with custom constructors, named arguments go to the default constructor, as the Map form does. The custom constructors don't run, and where constraints are still checked.
  • A type with a one-parameter constructor that takes a Map doesn't take named arguments: that constructor decides which keys it reads. Pass it the Map.

Types with Custom Constructors

Add a constructor function for validation or convenience by combining the struct definition with a function:

// Struct definition + constructor function in single declaration
Point2D type {
    x: Int,
    y: Int
}
fn (x: Int, y: Int): Point2D {
  Point2D({x: x, y: y})
}
Point2D(10, 20) → {x: 10, y: 20}

You can also define multiple constructor arities:

// Multiple constructor arities
Range type {
    start: Int,
    end: Int
}
fn
(end: Int): Range { Range({start: 0, end: end}) },
(start: Int, end: Int): Range { Range({start: start, end: end}) }
Range(10)    → {start: 0, end: 10}
Range(5, 15) → {start: 5, end: 15}

A Map argument goes to the default constructor, which is how a custom constructor builds its value with Name({...}). A one-parameter constructor whose type takes a Map gets Maps instead. Its type can be Map, Map<K, V>, Map?, or a union with Map in any position:

Temp type {
    celsius: Dec
}
fn (reading: Str | Map): Temp {
    data if(is-str(reading), {celsius: Dec(reading)}, {celsius: reading.c})
    Temp(data)
}

Temp("21.5").celsius     // 21.5
Temp({c: 30.0}).celsius  // 30.0

Inside the custom constructors, Temp({...}) still calls the default constructor, and so does Temp(data) in the constructor that takes Maps. If several constructors take a Map, one typed Map gets them before a union or nullable type.

Empty Types (Markers)

Types with no fields work as markers or tags:

Token type {

}

Admin type {

}

token Token()
admin Admin()

Enums (Variant Unions)

Define a type with multiple named variants using the enum keyword:

// Simple variants (no data)
Direction enum {
  Up,
  Down,
  Left,
  Right
}

// Create values
up-dir Direction.Up
down-dir Direction.Down

Variants with Data

Variants can carry data by referencing other types:

// Define the payload types first
Circle type {
    radius: Dec
}

Rectangle type {
    width: Dec,
    height: Dec
}

// Define the enum
Shape enum {
  Point,
  Circle(Circle),
  Rectangle(Rectangle)
}

// Create values
shape-point Shape.Point
shape-circle Shape.Circle({radius: 5.0})
shape-rect Shape.Rectangle({width: 10.0, height: 20.0})

Built-in Variant Types

Hot uses variant unions for core types:

// Result has Ok and Err variants
success Result.Ok(42)
failure Result.Err("Not found")

Type-Level and Variant-Level Matching

Use match to check variant types (preferred):

describe-direction fn (dir): Str {
  match dir {
    Direction.Up => "going up"
    Direction.Down => "going down"
    Direction.Left => "going left"
    Direction.Right => "going right"
  }
}

describe-direction(Direction.Up)    // "going up"
describe-direction(Direction.Down)  // "going down"

Use is-type for dynamic type checking:

Coord type {
    x: Int,
    y: Int
}

c Coord({x: 1, y: 2})

is-type(c, Coord)        // true
is-type("hello", Coord)  // false

Exhaustive Matching

match on a closed enum must cover every variant or fall through to a _ default arm. Union arms (A | B) count each variant they name, and an Any arm covers everything. The compiler reports [non-exhaustive-match] and lists the missing variants if none of these hold.

Direction enum { Up, Down, Left, Right }

// Exhaustive — every variant has an arm
travel fn match (d: Direction): Str {
  Direction.Up => { "north" }
  Direction.Down => { "south" }
  Direction.Left => { "west" }
  Direction.Right => { "east" }
}

// Also exhaustive — the union arm covers Up and Down, `_` catches the rest
classify fn match (d: Direction): Str {
  Direction.Up | Direction.Down => { "vertical" }
  _ => { "horizontal" }
}

Open Enums

Add the open modifier to declare an extensible enum. Other modules add new variants by declaring an arrow Source -> Enum.Variant. An open enum with no seed variants is a pure extension point:

// Pure extension point — no initial variants
Plugin enum open { }

// Open enum with some seed variants
Animal enum open {
  Dog,
  Cat
}

Because the variant set of an open enum is unbounded, match on an open enum must include a _ default arm — the compiler reports [open-enum-match-missing-default] otherwise.

greet-animal fn match (a: Animal): Str {
  Animal.Dog => { "woof" }
  Animal.Cat => { "meow" }
  _ => { "hello, friend" }   // required for open enums
}

Arrow Enrollment

A single arrow declaration both enrolls a variant in an open enum and synthesizes its constructor. The bodyless form is the idiomatic shorthand:

Bird type { species: Str, wingspan: Dec }
Bird -> Animal.Bird           // bodyless — wraps the source value as-is

eagle Animal.Bird({species: "Eagle", wingspan: 2.1})

The variant name does not need to match the source type name. This is a role-shaped variant, useful when a domain-specific tag reads better than the data type name:

Lizard type { length-cm: Dec }
Lizard -> Animal.Reptile      // tag is Reptile, payload is Lizard

Declaring the same arrow twice — even from different files — is a compile error ([ambiguous-type-implementation]) and the message points to both source spans.

Type Coercion

Define how types convert to each other using ->:

Date type {
  year: Int,
  month: Int,
  day: Int
}

// Define Date -> Str conversion (separate from type definition)
Date -> Str fn (date: Date): Str {
  `${date.year}-${date.month}-${date.day}`
}
d Date({year: 2024, month: 12, day: 25})
Str(d) → "2024-12-25"

Multiple coercions:

Temperature type { celsius: Dec }

Temperature -> Str fn (temp: Temperature): Str {
  `${temp.celsius}°C`
}

Temperature -> Int fn (temp: Temperature): Int {
  round(temp.celsius)
}

temp Temperature({celsius: 23.7})
Str(temp)   // "23.7°C"
Int(temp)   // 24

Arrow declarations (Source -> Target) must live at the top level of a namespace ([nested-type-implementation]). Arrows mutate the global implementation registry, so a nested arrow inside a function body would be a hidden global side effect — another part of the program could pick up the implementation without ever seeing it referenced near where it was declared. Local type definitions inside function bodies are still allowed; the rule applies only to arrows.

Arrow coercions are one-hop. Hot does not search a chain such as A -> B -> C to satisfy a parameter requiring C. If more than one direct arrow could satisfy the same conversion, the call is ambiguous rather than silently choosing one.

Result Types

Hot doesn't have exceptions. Instead, use Result for operations that can fail:

// Create results explicitly
success Result.Ok(42)
failure Result.Err("Not found")

// Using shorthand functions
success ok(42)
failure err("Not found")

// Check results
result safe-divide(10, 2)
if(is-ok(result),
  `Result: ${result}`,
  `Error: ${result}`)

The Result type is an enum with Ok and Err variants:

Result enum {
  Ok(Any),
  Err(Any)
}

success Result.Ok(42)
`Value: ${success}`   // "Value: 42" (auto-unwraps in templates)

Results automatically unwrap when used as function arguments: Ok values pass through, and Err values halt execution, unless the parameter is declared Result. This makes error propagation seamless.

See Error Handling for the full story on Result types, automatic unwrapping, and Result parameters.

Type Checking

hot check validates annotations, known record fields, call signatures, exhaustive matches, and other statically visible constraints. It does not turn Hot into a fully static language: Any, dynamically shaped maps, and values from unresolved boundaries may defer failures until runtime.

Structural compatibility and runtime identity answer different questions. A known record may satisfy a struct parameter by supplying every required field, while a value constructed with a type or enum constructor carries a nominal tag used by match, is-type, serialization, and overload dispatch.

Use is-* functions to check built-in types at runtime:

is-str(value)    // true if Str
is-int(value)    // true if Int
is-vec(value)    // true if Vec
is-map(value)    // true if Map
is-fn(value)     // true if function
is-null(value)   // true if null
is-some(value)   // true if not null

For custom types, use is-type:

Coord type {
    x: Int,
    y: Int
}

c Coord({x: 1, y: 2})

is-type(c, Coord)        // true
is-type("hello", Coord)  // false

The untype Function

Typed values carry internal metadata so Hot can preserve type identity at runtime. That wrapper is transparent to field access and is not a source-level reflection API: use normal field access, match, and is-type.

Collection functions see a typed value's fields too. get, get-in, keys, vals, and length read the fields. assoc, assoc-in, update, update-in, and merge return a value of the same type, like deep-path assignment, without re-running a custom constructor. Transforms such as map-vals, filter-keys, and delete, and map spread ({...server}), return plain maps of the fields.

to-json preserves that identity with Hot's tagged JSON representation, and from-json restores it. Treat the tag as a wire format rather than constructing or inspecting its metadata in Hot code. The serialized tag is an implementation boundary, not an alternate source-level type-construction or reflection API.

Call untype before serialization when a recipient expects ordinary untagged JSON:

// Define a type
Person type { name: Str, age: Int }

// Create a typed value
alice Person({name: "Alice", age: 30})

// Produce an untagged value
untype(alice)  // {name: "Alice", age: 30}

When to Use untype

The most common use case is serializing typed data to JSON for HTTP requests:

// Tagged JSON is the default for typed values
to-json(alice)

// Untype explicitly requests ordinary untagged JSON
to-json(untype(alice))  // {"name":"Alice","age":30}

Use untype when calling external APIs that expect untagged JSON payloads:

// Sending typed data to an external API
request-body ChatRequest({
  model: "gpt-4",
  messages: [{role: "user", content: "Hello"}]
})

// Untype before converting to JSON
::hot::http/request("POST", url, headers, to-json(untype(request-body)))

Recursive Untyping

The untype function works recursively—it strips type metadata from nested types as well:

Order type { customer: Person, items: Vec<Item> }

order Order({
  customer: Person({name: "Bob", age: 25}),
  items: [Item({name: "Widget", price: 9.99})]
})

// Recursively removes all type metadata
untype(order)
// {customer: {name: "Bob", age: 25}, items: [{name: "Widget", price: 9.99}]}

Summary

  • Types are optional — add them where they help
  • Types are constructors: Person(name: "Alice", age: 30) or Person({name: "Alice", age: 30}) creates a Person
  • Use ? for optional/nullable types: Str?
  • Use | for union types: Int | Str
  • Use literal unions for exact value sets: "apple" | "banana"
  • Use where for constraints: Port type Int where between(%, 1, 65535)
  • Use enums (variant unions) for discriminated types: Direction enum { Up, Down }
  • Define type coercions with Type -> OtherType fn
  • Use Result with Result.Ok()/Result.Err() instead of exceptions (see Error Handling)
  • to-json preserves typed values with tagged JSON; use untype when the target expects untagged JSON