Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Values & Types

The Last Light inn charges by the night, and the innkeeper does not haggle:

VAR gold: int = 12
VAR room_rate: float = 3.5

The innkeeper chalks the rate on the slate: {room_rate} coins a night.
With {gold} coins, you can stay {nights_affordable(gold, room_rate)} nights.
-> END

=== function nights_affordable(purse: int, rate: float): int ===
~ return int(float(purse) / rate)
The innkeeper chalks the rate on the slate: 3.5 coins a night.
With 12 coins, you can stay 3 nights.

Everything in that scene has a type: gold is an int, room_rate is a float, nights_affordable takes one of each and gives an int back. The compiler checked all of it before the story ran — the division that needed both sides to be floats, the conversion back down to whole nights, the interpolations. If any of it had been wrong, you’d have gotten a compile error naming the exact spot, not a story that quietly printed nonsense.

That checking posture — strict by default, inferred almost everywhere, declared at the edges — is the subject of this chapter.

Current spelling — examples in this chapter compile in today’s brink dialect: functions are ink-style === function … === knots and collection literals carry the #[…]/#{…} sigils. The ruled native .brink spellings — fn declarations, bare […] literals, [T]/[K: V] type notation — arrive with the native frontend, and this chapter’s examples will be respelled then.

One checker, two policies

Every brink-dialect project has a type checker running underneath it, whether or not it ever shows up. types = strict and types = gradual are two policies over the same checker — not two type systems — so the policy never changes what a program means, only whether the compiler insists on proving more about it before it’s allowed to run.

Strict is the language’s posture. Since the 2026-07-19 typing-posture ruling, a brink-dialect project with no types setting is strict; the native .brink surface, when it lands, is strict-only. You opt out per project, in brink.toml:

[project]
dialect = "brink"
types   = "gradual"   # explicit opt-out; omitting `types` means strict here

Gradual is the compat floor, and it is permanent. It is the strict-ink dialect’s default forever — the mode every plain-ink project is in. Types are still inferred internally for tooling’s benefit (hover, inlay hints, advisory diagnostics), but nothing you write is required to resolve to a concrete type: a slot the checker can’t pin down stays Unknown, and Unknown defers to the runtime’s usual coercion behavior, unchanged from how ink has always worked. Adding type annotations under gradual doesn’t opt you into anything stricter by itself.

Turning strict on changes three things, and only these three:

  • An Unknown that would otherwise have escaped inference becomes a compile error (E065, “annotate or restructure”) instead of a silent fallback.
  • The coercion lattice narrows (see below) — most cross-type operations that gradual mode quietly tolerates become errors.
  • Every variable becomes mono-typed, and collection element types must unify per collection (#[1, 2.0] is fine — see the note on int → float below — but #[1, "a"] is a compile error).

Strict mode requires the brink dialect (its annotation syntax is dialect extension syntax, same as blocks and collection literals). Asking for types = strict under strict-ink is a targeted config error, not a silent no-op:

error[E064]: types = strict requires dialect = brink — strict typing's
annotation syntax is a brink-dialect extension (docs/typed-mode-spec.md §1);
set `dialect = brink` or drop back to `types = gradual`

The oracle-anchored strict-ink subset — the plain-ink corpus this whole compiler is validated against — is untouched by construction: a strict-ink project always resolves to gradual, so strict typing only ever applies to a project that has already opted into the brink dialect.

The value kinds

Every value in a running story is one of a small, closed set of kinds. The first four are the ones every scene touches:

  • int — whole numbers: gold coins, visit counts, dice pips. Ink’s native integer, 32-bit.
  • float — fractional numbers: rates, weights, distances.
  • booltrue/false. Conditions want one (with one deliberate exception — see the visit-count idiom below).
  • string — text as a value: names, keys, anything you compare or store rather than merely print.

Beyond the scalars: divert (a knot/stitch target held as a value, -> market in value position), arrays and maps (Collections and Indexing & Mutation), LISTs (ink’s flag-set type, nominal per declaration), structs (declared shapes, below), function values (Function Values), ranges, the numeric tower (vec2mat4), handles (host-owned resources, typed by the engine’s manifest), and content (a first-class, fragment-capture-backed value — the type of a captured prose run, e.g. an annotated handler’s text: content parameter).

When you write a type — in any annotation position — primitives are lowercase and every other type name is Uppercase:

Written asMeaning
int, float, bool, stringthe scalar kinds
contenta captured prose run (fragment-capture-backed)
diverta divert target as a value
voidreturn position only: “this function returns nothing”
List<L>L names a declared LIST
Array<T>, Map<K, V>typed collections
Option<T>, Weighted<T>see below
fn(T…): Ra function value (Function Values)
vec2 vec3 vec4 quat mat2 mat3 mat4the numeric tower
Handle<K>K names a handle kind from the host manifest
a declared STRUCT namethat struct’s shape

A name outside this vocabulary is E061 (“ is not a recognized type”), which lists exactly the table above. Option<T> appears in inferred types, diagnostics, and the Standard Library’s signatures (find, map get), and — like Weighted<T> — is now annotatable too (issue #1552); a bare none with no surrounding context to type it is still its own error (E107, “bare none needs a type from context”). range stays construction-only for now: no annotation spelling yet.

Inferred inside, declared at the edges

You will write far fewer annotations than the previous sections might suggest, because the compiler reads your function bodies and works signatures out bottom-up through the call graph:

-> ledger

=== function tab(nights) ===
~ return nights * 4

=== ledger ===
Three nights at four coins each: {tab(3)} coins.
-> DONE
Three nights at four coins each: 12 coins.

tab carries no annotations, and under strict that’s fine: the body multiplies its parameter by a whole number, and that’s evidence enough — inference settles the signature as (int) -> int on its own. Mutually recursive helpers are solved together as a group, and each definition gets exactly one concrete signature, never a generic one.

Note where the evidence came from: the body, only ever the body. The call tab(3) is checked against the settled signature; it never feeds into it. This directionality is deliberate — a definition’s meaning can’t be changed at a distance by whoever happens to call it — and it has one consequence you’ll meet in practice: a parameter whose body-uses are all type-neutral (interpolation, passing it along to another unconstrained slot) has no evidence to settle on, no matter how plainly its call sites type it. That’s what the Unknown escape below is about.

Annotations are required at exactly one place: boundaries. A boundary is any signature the compiler can’t see through from the call graph alone — host-callable entry points, externals crossing to the engine, anything whose callers aren’t all visible. Internal helper functions never require an annotation. There is no separate “you forgot to annotate a boundary” diagnostic; the requirement enforces itself through inference: a boundary parameter no body-use pins down escapes as Unknown, and strict mode turns that escape into E065 at the definition — annotate it and the error goes away.

The practical corollary: an inferred signature ripples to its callers when the body changes. That’s an accepted, on-the-record cost — a body bug can surface as an error at a caller rather than at its own definition — in exchange for never forcing annotations onto code that doesn’t cross a boundary.

Annotations

There is exactly one way to write a type: name: type after the thing it types, and ): type in a function header’s return position. It works on parameters, return types, VAR and CONST declarations, and ~ temp ascriptions:

VAR gold: int = 100
CONST RATE: float = 1.5

~ temp name: string = "hero"
~ gold = heal(gold, 10)

{name} has {gold} gold at rate {RATE}.
-> END

=== function heal(hp: int, amount: int): int ===
~ return hp + amount
hero has 110 gold at rate 1.5.

Annotations are optional almost everywhere — the ones on heal above are documentation more than necessity, since inference would have found the same signature. But an annotation is never just a comment. It is a firewall: the declared type is what callers see, whatever the body does, and the checker then verifies the body against the declaration. When the two disagree, that’s E063 — “annotated type string disagrees with the type inferred from usage (int)”:

-> tally("a quiet night")

=== tally(count: string) ===
{count > 2:
    A crowd tonight.
}
-> DONE

Under types = gradual, E063 stays a warning — advisory seasoning. Under types = strict it is promoted to a hard error: a signature that lies about its body is exactly the kind of latent bug strict mode exists to catch.

E063 isn’t only about the param/return firewall above. The same code fires for a VAR/CONST/~ temp declaration initializer that disagrees with its own annotation (VAR v: int = "hi"), and for a plain assignment that disagrees with its target’s already-known declared type — a VAR/CONST (annotated or not: an unannotated VAR v = 5’s declared type is still the initializer’s own inferred type) or an annotated ~ temp. A Param target is the one exception: a param disagreement is always caught by the firewall check above instead, at the annotation itself.

It also fires for a UFCS-desugared call’s arguments, on the native (.brink) surface: recv.name(args) desugars to name(recv, args), and both the receiver (standing in for the desugar’s first argument) and every written argument are checked against name’s already-known declared param types — g.greet(3) reports E063 when greet’s declared first param disagrees with g’s own type, exactly like a direct call to greet would.

A function that returns nothing annotates void (or simply never returns a value — inference treats the two identically). Assigning the result of a void call is a strict-mode error, E067 (“f returns void — its result cannot be assigned”): there’s nothing there to assign.

When inference can’t answer: Unknown and Conflicted

Strict mode’s two signature errors are worth telling apart, because they ask for different fixes.

Unknown means inference ran out of evidence. Nothing the body does pins the slot down — and note that printing a value is not evidence, because interpolation accepts every type. This fails with E065, “serve’s parameter dish escapes strict inference as Unknown — annotate or restructure”:

-> serve

=== serve(dish) ===
The innkeeper slides {dish} across the bar.
-> DONE

The fix is in the message: either annotate (=== serve(dish: string) === — the annotation supplies exactly the fact inference couldn’t find) or restructure so the body genuinely uses the value.

Conflicted means the body disagrees with itself. The slot isn’t unconstrained — it’s over-constrained, used as two irreconcilable types. This fails with E066, “haggle’s parameter offer is Conflicted under strict types — its uses disagree on its type”:

-> haggle

=== haggle(offer) ===
{offer > 10:
    The trader whistles.
}
{offer == "generous":
    He bows.
}
-> DONE

No annotation can fix a Conflicted slot — declaring offer: int doesn’t make offer == "generous" sensible; it just moves where the contradiction is reported. The only fix is to make the body agree with itself.

Under gradual, both cases fall back silently to runtime coercion behavior. That’s the whole policy difference in one sentence: gradual defers these questions to the runtime; strict refuses to compile until they’re answered.

The coercion lattice, under strict

Gradual mode’s coercions are exactly what ink has always done. Strict mode narrows the lattice to one rule and two escape hatches.

int → float is the one implicit, directional promotion. An int is welcome anywhere a float is expected — #[1, 2.5] is a well-typed Array<float>, VAR rate: float = 1 is legal, and an int argument promotes to match a float parameter:

-> weigh

=== function heft(w: float): string ===
{ w > 2.0:
    ~ return "heavy"
- else:
    ~ return "light"
}

=== weigh ===
The innkeeper's ledger calls it a {heft(3)} purse.
-> DONE
The innkeeper's ledger calls it a heavy purse.

There is no float → int direction; that would silently lose precision.

Everything else is explicit, through the pure conversion intrinsics int(x), float(x), and string(x):

VAR fare: float = 2.5

~ temp coins: int = int("12")
You hand over {coins} coins; the ferryman counts {float(coins) * fare} in value.
-> END
You hand over 12 coins; the ferryman counts 30 in value.

int and float accept numbers, bools, and strings (a string is parsed; a string that doesn’t parse is a turn-terminating fault, never a silent zero). Anything outside that domain — a divert, a LIST, a collection — is a compile error under strict (E078) and a runtime fault under gradual. string(x) accepts every value and never fails: it’s the same display form interpolation uses.

Interpolation is universal, not a coercion. {x} accepts every type under both policies — display was never part of the type lattice to begin with.

The idiom that survives strict: visit counts in condition position

Ink’s oldest idiom is checking a knot’s visit count directly in a condition:

-> market ->
{market: The stalls are familiar now.|A first look at the market.}
-> END

=== market ===
Fruit, dice, gossip.
->->
Fruit, dice, gossip.
The stalls are familiar now.

market’s visit count is a plain int; used bare in condition position, nonzero means true. Nothing about strict typing touches this — it’s scoped deliberately: condition position only. Elsewhere, an int used where a bool is expected is still an error under strict (VAR ready: bool = 3 never becomes legal), because that’s not the idiom being preserved — this is. A strict mode that broke visit-count conditionals would be unusable for real ink content, so this is a floor: turning strict on should produce errors only where types genuinely conflict, never on ordinary visit-count logic.

One neighboring idiom does not survive, on purpose: an Option in condition position. {mood.first(): …} is not “is there a first mood” — Option<T> has no truthiness, ever. The condition-position error (E116) tells you the honest spelling: test == none / == some(x) explicitly. A fault says “your program is wrong”; none says “the world didn’t have one” — and a bare truthiness test blurs exactly that line.

Collections and the empty-literal rule

Under strict, element types unify per collection: every element in an #[…] (after the int → float join) must agree on one type, or the collection’s type is Conflicted and the binding holding it fails with E066:

-> count_loot

=== count_loot ===
~ temp loot = #[1, "pearl"]
{len(loot)} treasures.
-> DONE

An empty literal (#[], #{}) takes its type from surrounding context — an already-typed binding, a typed argument position. If nothing constrains it, that’s an Unknown escape (E065): annotate the binding.

-> pack

=== pack ===
~ temp satchel: Array<int> = #[]
~ push(satchel, 3)
{len(satchel)} item in the satchel.
-> DONE
1 item in the satchel.

Map keys type as the built-in key domain (int, string, or bool) as a single key sort, not a general union — brink has no user-facing union types in this version. The full indexing and mutation contracts live in Indexing & Mutation.

Structs

This section will move to its own chapter (structs, enums, and flags) as the book’s reorganization proceeds; it lives here until that chapter exists.

Structs are a closed-shape, flat-field value type. Declaring one mirrors how you construct one: the STRUCT body is the same braced shape as a construction literal, with types where the construction literal has values.

STRUCT Point = #{
    x: float,
    y: float,
}

VAR p = 0

~ {
    p = Point#{x: 1.0, y: 2.0}
    p.x = 9.0
}

{p.x} {p.y}
-> DONE
9 2

Field access (p.x) resolves through the same fallback rule brink uses for direct-call syntax elsewhere: ink’s own static dotted paths (knot.stitch, List.Item) are tried first and win; .x is field access only once the head resolves to a plain variable. Under strict the shape is known at compile time, so field reads and writes compile to static offsets; under gradual, an Unknown-typed head defers the lookup to runtime, by name.

Structs work under either policy, with different failure timing for the same mistake. A construction literal is checked against the declared shape — missing a field is E069, supplying an undeclared one is E070, a field value of the wrong type is E071, and naming the same field twice is E084 (under both policies — a duplicate’s initializer is never silently dropped). Under strict these are compile errors; under gradual the malformed construction is a runtime fault that ends the story turn instead of producing a half-built value. Neither policy silently accepts a malformed construction — only when the mismatch is caught differs.

A construction literal is a legal VAR/CONST declaration default, so a struct-typed global can be given its real starting value where it is declared. The literal has to be well-formed there: a declaration default is baked into the compiled story, with no runtime construction step left to fault at, so a mismatched one is a compile error under either policy rather than a gradual-mode runtime fault — E075 under gradual, or the same missing/extra-field E069/E070 the analyzer already reports for a mismatched construction under strict (structs::check blocks the compile before LIR lowering, so E075’s declaration-default path is never reached). One thing to keep in mind either way: initializers in a construction literal always evaluate in the order you wrote them, never the shape’s declared order.

A struct passed to or returned from a function behaves like any other value: the callee gets its own independent copy, and mutating it never reaches back to the caller’s — the same value semantics arrays and maps have.

Types at the seams

Inside one project, inference sees everything. At the project’s edges it can’t, and each edge has its own answer:

  • Engine functions (EXTERNAL) have bare, untyped parameters in ink source — their types come from the host’s binding manifest (see External Functions). A registered external whose manifest types resolve is checked at every call site like any other function; an external with no registered signature stays deliberately unchecked. Under strict, a manifest type that fails to resolve is a real E065 escape at the declaration — the seam must be typed, or it’s an error, never a shrug.
  • The ink seamplanned. When the native .brink surface lands, ink-authored symbols will enter native code as Unknown, and strict’s existing escape rule does the rest: annotate at the seam, exactly the boundary doctrine above. Today there are no mixed ink/brink trees, so this seam exists as a ruling, not yet as tooling; the compat posture is described in Conformance.

Reference: the diagnostics in this chapter

CodeFires whenPolicy
E061annotation names an unrecognized typeboth
E063annotated type disagrees with the type inferred from usage, or a VAR/CONST/~ temp declaration initializer or a plain assignment disagrees with its target’s already-known declared type, or (native surface) a UFCS-desugared call’s receiver or written argument disagrees with the desugared function’s declared param typewarning under gradual, error under strict
E064types = strict without dialect = brinkconfig
E065a type escapes strict inference as Unknownstrict
E066a type is Conflicted — its uses disagreestrict
E067assigning the result of a void functionstrict
E069/E070/E071struct construction missing / extra / mistyped fieldstrict (runtime fault under gradual)
E075struct construction literal in a VAR/CONST default doesn’t match its declared shapegradual (strict reports E069/E070 first)
E078int()/float() argument outside the numeric+bool+string domainstrict (runtime fault under gradual)
E084duplicate field in a struct construction literalboth
E107bare none with no type from contextboth
E116Option<T> used as a condition — no truthinessstrict (runtime fault under gradual)

Where this is ruled

  • Typed modedocs/typed-mode-spec.md §§1–5 (policy, inference, annotation syntax, coercion lattice, collections); §6 (structs).
  • Strict as the default; native strict-only — decision log 2026-07-19, “Typing posture ruled” (NS-A9); the dialect-keyed default in resolve_type_policy.
  • Option, absence, and no truthinessdocs/stdlib-spec.md §1 (Phase A postures; F27 ruled 2026-07-19).
  • Struct construction order and duplicate fields — decision log 2026-07-14 (#675/#676).