Type annotations
Harn has an optional, gradual type system. Type annotations are checked at compile time but do not affect runtime behavior. Omitting annotations is always valid.
Basic types
const name: string = "Alice"
const age: int = 30
const rate: float = 3.14
const ok: bool = true
const nothing: nil = nil
The never type
never is the bottom type — the type of expressions that never produce a
value. It is a subtype of all other types.
Expressions that infer to never:
throw exprreturn exprbreakandcontinue- A block where every control path exits
- An
if/elsewhere both branches infer tonever while true { ... }whose body contains nobreakbinding to that loop (abreakinside a nested loop binds the inner loop and does not count), so a function whose tail is such a loop needs no trailingreturn- Calls to
unreachable()
never is removed from union types: never | string simplifies to
string. An empty union (all members removed by narrowing) becomes
never.
fn always_throws() -> never {
throw "this function never returns normally"
}
The any type
any is the top type and the explicit escape hatch. Every concrete
type is assignable to any, and any is assignable back to every
concrete type without narrowing. any disables type checking in both
directions for the values it flows through.
fn passthrough(x: any) -> any {
return x
}
const s: string = passthrough("hello") // any → string, no narrowing required
const n: int = passthrough(42)
Use any deliberately, when you want to opt out of checking — for
example, a generic dispatcher that forwards values through a runtime
protocol you don't want to describe statically. Prefer unknown (see
below) for values from untrusted boundaries where callers should be
forced to narrow.
The unknown type
unknown is the safe top type. Every concrete type is assignable to
unknown, but an unknown value is not assignable to any
concrete type without narrowing. This is the correct annotation for
values arriving from untrusted boundaries (parsed JSON, LLM responses,
dynamic dicts) where callers should be forced to validate the shape
before use.
fn describe(v: unknown) -> string {
// Direct use of `v` as a concrete type is a compile-time error.
// Narrow via type_of/schema_is first.
if type_of(v) == "string" {
return "string: ${v.upper()}"
}
if type_of(v) == "int" {
return "int: ${v + 1}"
}
return "other"
}
Narrowing rules for unknown:
type_of(x) == "T"narrowsxtoTon the truthy branch (whereTis one of the type-of protocol names:string,int,float,bool,nil,list,dict,closure,bytes).schema_is(x, Shape)narrowsxtoShapeon the truthy branch.guard type_of(x) == "T" else { ... }narrowsxtoTin the surrounding scope after the guard.- The falsy branch keeps
unknown— subtracting one concrete type from an open top still leaves an open top. The checker still tracks which concretetype_ofvariants have been ruled out on the current flow path, so an exhaustive chain ending inunreachable()/throwcan be validated; see the "Exhaustive narrowing onunknown" subsection of "Flow-sensitive type refinement". - These rules apply equally when
xis a reference path whose type isunknown(e.g. anunknown-typed field reached viao.data), not only a bareunknownvariable — including the ruled-out tracking for the exhaustiveness check.
Interop between any and unknown:
unknownis assignable toany(upward to the full escape hatch).anyis assignable tounknown(downward — theanyescape hatch lets it flow into anything, includingunknown).
When to pick which:
- No annotation — "I haven't annotated this." Callers get no checking. Use for internal, unstable code.
unknown— "this value could be anything; narrow before use." Use at untrusted boundaries and in APIs that hand back open-ended data. This is the preferred annotation for LLM / JSON / dynamic dict values.any— "stop checking." A last-resort escape hatch. Preferunknownunless you have a specific reason to defeat checking bidirectionally.
Member access and nil safety
Three syntactic forms dereference a receiver value at runtime — property
read (obj.field), subscript (obj[key]), and method call
(obj.method(..)). All three fail identically when the receiver is nil
or is not the kind of value the access expects, so the checker applies one
consistent set of diagnostics to all three rather than treating property
access as special:
| Receiver type | obj.field / obj[key] / obj.m() | obj?.field / obj?.[key] / obj?.m() |
|---|---|---|
statically nil | error — known nil here | allowed (the ? short-circuits) |
T | nil (nilable) | error — may be nil at runtime | allowed |
unknown | warning — narrow or validate first | warning — ? only guards nil, not a non-shape value |
any | no diagnostic (checking opted out) | no diagnostic |
concrete (struct, shape, list, …) | field/index/method checked against the type | unnecessary-? lint if the receiver can't be nil |
The fix for a nil / nilable receiver is always one of: the matching
optional operator (?., ?.[…], or ?.m()), a != nil guard that
narrows the value, or a ?? default. For an unknown receiver, narrow
with is_a / type_of, validate with assert_shape / schema_is, or
add a shape annotation. any is the deliberate escape hatch and is never
diagnosed — see the any vs unknown guidance above.
Two narrowings keep this rule ergonomic. A guard on an optional-access
chain narrows the base identifier: inside if o?.field != nil { … }
the value o is non-nil (if o were nil, o?.field would be nil), so a
plain o.field read in that branch is allowed. And value ?? default
drops the nil arm of value even when value's type is a named
alias that expands to a nilable union (type Opts = {…} | nil), so the
common let opts = options ?? {} option-defaulting idiom yields a non-nil
value.
These diagnostics fire only when the receiver's type comes from a real
contract — a written annotation, a named struct / alias / enum, a
call-return, or any non-identifier expression. The ambient dict-literal
idiom (let d = {a: 1}; d.missing) stays loose and returns nil at
runtime, matching the gradual-typing affordance for one-off glue.
Open records and row polymorphism
A shape type may end with one or more row tails — ... followed by a type.
A row tail stands for "any other fields," so the shape is open:
// `x` is any record that has at least a string `id`; `rest` captures the
// other fields, whatever they are.
fn needs_id(x: {id: string, ...rest}) -> string {
return x.id
}
needs_id({id: "u1", name: "Ann", age: 3}) // ok — extra fields allowed
needs_id({name: "Ann"}) // error — required `id` missing
rest is a row variable: an ordinary generic type parameter that happens
to appear in tail position. A function generic over rows types record merge
precisely — the result carries every field of both inputs, with the right-hand
fields overriding the left on overlap (matching the runtime of merge /
{...a, ...b} / a + b):
fn merge<R1, R2>(a: {...R1}, b: {...R2}) -> {...R1, ...R2} {
return a + b
}
const m = merge({a: 1, b: 2}, {b: "x", c: true})
// m : {a: int, b: string, c: bool} (b overridden by the right side)
Rules:
- Subtyping checks only the explicit fields of the expected side against
the actual's known fields (Harn shapes are width-subtyped, so extra actual
fields are always allowed and the row tail absorbs them). A required field
absent from the actual's known fields is accepted only when the actual has a
gradual tail (
dict,any); an abstract row variable is not assumed to supply it. - Binding is one-sided: a row variable binds to the actual record's
leftover fields (the fields not matched by the explicit ones). With no
explicit fields,
{...R}bindsRto the whole record. - Override on overlap is right-biased; the result field is required if either side is required, and its type is the right field's type (or the union of both when the right field is optional).
- Gradual interop:
dictis the dynamic row — spreading or merging adict-typed value yields adictrather than a falsely-precise closed shape. Precision is monotone: you never get a more precise result than the inputs justify.
Union types
const value: string | nil = nil
const id: int | string = "abc"
Union members may also be literal types — specific string or int values used to encode enum-like discriminated sets:
type Verdict = "pass" | "fail" | "unclear"
type RetryCount = 0 | 1 | 2 | 3
const v: Verdict = "pass"
Literal types are assignable to their base type ("pass" flows into
string), and a base-typed value flows into a literal union (string
into Verdict). Runtime schema_is / schema_expect guards and the
parameter-annotation runtime check reject values that violate the
literal set.
A match on a literal union must cover every literal or include a
wildcard _ arm — non-exhaustive match is a hard error.
Tagged shape unions (discriminated unions)
A union of two or more dict shapes is a tagged shape union when the
shapes share a discriminant field. The discriminant is auto-detected:
the first field of the first variant that (a) is non-optional in every
member, (b) has a literal type (LitString or LitInt), and (c) takes
a distinct literal value per variant qualifies. The field can be named
anything — kind, type, op, t, etc. — there is no privileged
spelling.
type Msg =
{kind: "ping", ttl: int} |
{kind: "pong", latency_ms: int}
Matching on the discriminant narrows the value to the matching variant
inside each arm; the same narrowing fires under
if obj.<tag> == "value" / else:
type Msg =
{kind: "ping", ttl: int} |
{kind: "pong", latency_ms: int}
fn handle(m: Msg) -> string {
match m.kind {
"ping" -> { return "ttl=" + to_string(m.ttl) }
"pong" -> { return to_string(m.latency_ms) + "ms" }
}
}
Such a match must cover every variant or include a wildcard _ arm
— non-exhaustive match is a hard error.
Distributive generic instantiation
Generic type aliases distribute over closed-union arguments. Writing
Container<A | B> is equivalent to Container<A> | Container<B> so
each instantiation independently fixes the type parameter. This is what
keeps processCreate: fn("create") -> nil flowing into a list< ActionContainer<Action>> element instead of getting rejected by the
contravariance of the function-parameter slot:
type Action = "create" | "edit"
type ActionContainer<T> = {action: T, process_action: fn(T) -> nil}
ActionContainer<Action> resolves to ActionContainer<"create"> | ActionContainer<"edit">, and a literal-tagged shape on the right flows
into the matching branch.
Intersection types
type BaseCtx = {request_id: string}
type AuthCtx = {user_id: string}
fn use_ctx(ctx: BaseCtx & AuthCtx) -> string {
return ctx.request_id + "/" + ctx.user_id
}
A & B requires the value to satisfy every component. The intersection
of two shape types behaves like a dict that has every field from each
component, so ctx.request_id and ctx.user_id are both accessible
above. Shape components may be inline or named aliases; the operator
nests freely (A & B & C).
& binds tighter than |, so A & B | C parses as (A & B) | C. Use
parentheses to write the union-of-intersections form.
At runtime, an intersection annotation lowers to a JSON-Schema allOf
guard. A value that is missing a field required by any component is
rejected by the parameter-annotation runtime check just like a single
shape mismatch is.
Parameterized types
const numbers: list<int> = [1, 2, 3]
const also_numbers: [int] = [1, 2, 3]
const headers: dict<string, string> = {content_type: "json"}
[T] is shorthand for list<T> in type positions. User-defined functions,
structs, enums, interfaces, and type aliases may declare type parameters with
<T, U>. Function calls normally infer those parameters from arguments, but
callers may pass them explicitly when inference needs help or when the desired
instantiation should be documented at the call site:
fn map<T, U>(xs: [T], f: fn(T) -> U) -> [U] { ... }
const labels: [string] = map<int, string>([1, 2, 3], label)
Explicit type arguments are erased at runtime. They are checked statically: the number of supplied type arguments must match the function declaration, and each explicit binding must remain consistent with the concrete argument types.
Inference descends through transparent generic type aliases, including aliases nested inside lists, dictionaries, records, callbacks, nullable types, and unions. Concrete candidates from multiple values join under the usual union rules. An empty collection contributes no candidate, so callers may use an explicit type argument when its element type is otherwise ambiguous:
type Step<T> = {value: T}
fn values<T>(steps: list<Step<T>>) -> list<T> { ... }
const inferred: list<int> = values([int_step()])
const explicit: list<int> = values<int>([])
Structural types (shapes)
Dict shape types describe the expected fields of a dict value. The type checker verifies that dict literals have the required fields with compatible types.
const user: {name: string, age: int} = {name: "Alice", age: 30}
Optional fields use ? and need not be present:
const config: {host: string, port?: int} = {host: "localhost"}
Width subtyping: a dict with extra fields satisfies a shape that requires fewer fields.
fn greet(u: {name: string}) -> string {
return "hi ${u["name"]}"
}
greet({name: "Bob", age: 25}) // OK — extra field allowed
Option-bag parameters are stricter for literal calls. When a parameter is
named like opts, options, or config, or its annotation is a type alias
ending in Options or Config, a direct dict literal argument may only use
fields declared by the option shape:
type PickOptions = {drop_nil?: bool}
fn pick(_options: PickOptions = {}) {}
pick({drop_nil: true}) // OK
A typo in a direct option literal is a type error:
pick({dropnil: true}) // type error — unknown option `dropnil`
Nested shapes:
const data: {user: {name: string}, tags: list} = {user: {name: "X"}, tags: []}
Shapes are compatible with dict and dict<string, V> when all field values match V.
Type aliases
type Config = {model: string, max_tokens: int}
const cfg: Config = {model: "gpt-4", max_tokens: 100}
A type alias can also drive schema validation for structured LLM output
and runtime guards. schema_of(T) materializes an alias as a JSON-Schema
dict:
type GraderOut = {
verdict: "pass" | "fail" | "unclear",
summary: string,
findings: list<string>,
}
// Use the alias directly wherever a schema dict is expected.
const s = schema_of(GraderOut)
const ok = schema_is({verdict: "pass", summary: "x", findings: []}, GraderOut)
const r = llm_call(prompt, nil, {
provider: "openai",
output: GraderOut, // alias in value position — compiled to schema_of(T)
schema_retries: 2,
})
llm_call can also express routing intent without pinning a single
provider/model pair. The route_policy option accepts:
"manual"(default): use the normalprovider/model/ env resolution."always(id)": pin to a model alias, model id, orprovider:modelselector."cheapest_over_quality(t)": select the lowest-cost available catalog candidate whose model tier is at leastt."fastest_over_quality(t)": select the lowest-latency available catalog candidate whose model tier is at leastt.
The optional fallback_chain is an ordered list of provider ids to try when
the selected provider fails availability or transport. Routing decisions are
recorded in LLM transcript events with the selected route plus all considered
alternatives so costs can be re-scored later:
const r = llm_call(prompt, nil, {
route_policy: "cheapest_over_quality(mid)",
fallback_chain: ["local", "ollama", "openai"],
})
System prompt fragments can be supplied without hand-concatenating the
positional system string. The system option accepts either a string or an
ordered list of {content, title?, position?: "before"|"after", enabled?}
fragments for llm_call and agent_loop. In persistent
agent_loop sessions, the composed session-level system prompt is recorded
once in transcript metadata and as one leading internal system_prompt
fingerprint event; it is not injected into the replayable message list. A later
continuation that omits all system prompt fields reuses the stored session
prompt for the provider request without writing another transcript event.
Internal _system_fragments entries may set bucket: "before" (default) or
bucket: "after"; the latter is used for live prompt-tail recitations such as
the agent scratchpad.
import { system_before, with_system_fragments } from "std/llm/prompts"
const opts = with_system_fragments(
{provider: "anthropic", session_id: "review-42"},
[system_before("Follow the repository's validation gate before final output.")]
)
const r = agent_loop("Review this change", "You are a code review agent.", opts)
For call sites that want routing policy to be visibly scoped around the work,
cost_route installs an inherited LLM routing context for the dynamic extent
of its block. Nested llm_call invocations inherit the block's routing and
budget options; an explicit option on the call wins for the same key.
const r = cost_route {
budget: {max_cost_usd: 0.05}
route_policy: {
mode: "preference_list",
targets: ["anthropic:claude-haiku-4-5", "openai:gpt-5.4-mini"],
strategy: "cheapest_first",
}
llm_call(prompt, nil, {max_tokens: 800})
}
The block config accepts the same canonical keys as llm_call; unknown or
removed keys are errors. In preference_list mode, targets is the ordered
set of model aliases, model ids, or provider:model selectors and strategy
selects prefer_order, cheapest_first, or fastest_first. Failures on the
selected route advance through the remaining targets before provider-level
fallbacks are considered.
For call sites that want Harn-managed response reuse, std/llm/handlers
exports with_cache(prompt, system?, options?). It returns the same envelope as
llm_call, but first checks a persistent content-addressed cache. The key is
sha256: plus canonical JSON over {prompt, system, provider, model, temperature, top_p, max_tokens} after provider/model defaults resolve. Cache
storage defaults to a sqlite store under Harn state with namespace
llm.with_cache, a 10-minute TTL, and LRU eviction at 256 entries. Calls with
options.tools != nil bypass the cache by default because tool results can
carry side effects; callers may set skip_when to a bool or predicate closure
to override that policy.
import { with_cache } from "std/llm/handlers"
const r = with_cache("Summarize this file", nil, {
provider: "anthropic",
model: "claude-haiku-4-5",
store: {backend: "fs", namespace: "summaries"},
ttl: "10m",
max_entries: 256,
})
std/cache exposes the underlying {hit, value?} primitive with
cache_get(key, options?), cache_put(key, value, options?), and
cache_clear(options?). Cache options accept either store: "namespace" or
store: {backend: "sqlite"|"fs", namespace?, path?} plus ttl,
ttl_seconds, max_age_seconds, and max_entries.
The emitted schema follows canonical JSON-Schema conventions (objects
with properties/required, arrays with items, literal unions as
{type, enum}) so it is compatible with structured-output validators
and with ACP ToolAnnotations.args schemas. The compile-time lowering
applies when the alias identifier appears as:
- The argument of
schema_of(T). - The schema argument of
schema_is,schema_expect,schema_parse,schema_check,schema_report,is_type,json_validate. - The value of an
output:entry in anllm_calloptions dict.
Public aliases keep the same reflection behavior when imported from a file or embedded standard-library module. Materialization resolves nested imported aliases, applied generic aliases, optional fields, and open-record tails in the module environment before ordinary module initialization. A consumer-owned alias may therefore embed an imported alias without changing its schema.
For aliases not known at compile time (e.g. let T = schema_of(Foo)
or dynamic construction), passthrough through the runtime schema_of
builtin keeps existing schema dicts working.
Generic inference via Schema<T>
Schema-driven builtins are typed with proper generics so user-defined wrappers pick up the same narrowing.
llm_call<T>(prompt, system, options: {output: Schema<T>, ...}) -> {data: T, text: string, ...}llm_completion<T>has the same signature.llm_call_structured<T>(prompt, schema: Schema<T>, options?) -> Tllm_call_structured_safe<T>(prompt, schema: Schema<T>, options?) -> {ok: bool, data: T | nil, error: dict | nil}llm_call_structured_result<T>(prompt, schema: Schema<T>, options?) -> {ok: bool, data: T | nil, raw_text: string, error: string, error_category: string | nil, attempts: int, repaired: bool, extracted_json: bool, usage: {input_tokens: int, output_tokens: int, cost_usd: float | nil, cache_read_tokens: int, cache_write_tokens: int, cache_hit_ratio: float | nil, cache_visibility: string | nil, cache_savings_usd: float, served_fast: bool, provider_telemetry: dict | nil}, model: string, provider: string}. Never throws on transport / schema failures — callers dispatch onok/error_category. Recognizederror_categoryvalues:transport-class categories pass through the underlying enum (rate_limit,timeout,auth,transient_network, ...); JSON / schema failures surface asmissing_json,schema_validation, orrepair_failedwhen an optional repair pass was attempted and also failed. Options accept arepair: {enabled: bool, ...llm_call_overrides}block — the repair pass runs a single shot on malformed JSON only and is skipped on transport-layer failures.schema_parse<T>(value: unknown, schema: Schema<T>) -> Result<T, string>schema_check<T>(value: unknown, schema: Schema<T>) -> Result<T, string>schema_expect<T>(value: unknown, schema: Schema<T>) -> Tschema_recover<T>(text: string, schema: Schema<T>, options?: {repair?: bool | dict, apply_defaults?: bool, ...llm_call_overrides}) -> {ok: bool, data: T | nil, raw_text: string, error: string, error_category: string | nil, attempts: int, stage: string, repaired: bool}. Best-effort recovery of malformed LLM output against a target schema. Three deterministic stages followed by an optional one-shot LLM repair:parsed(directserde_jsonparse) →extracted(lift JSON from prose / code fences) →regex(scrape top-levelkey: valuelines for scalar fields) →llm_repair(single-shotllm_callwithschema_retries: 0).stagereports which stage produced the result;failedmeans every stage exhausted. Set{repair: false}for a fully deterministic recovery pass with no LLM calls. The LLM repair stage accepts the same overrides asllm_call_structured_result'srepair.
Schema<T> denotes a runtime schema value whose static shape is T.
In a parameter position, matching a Schema<T> against an argument
whose value resolves to a type alias (directly, via schema_of(T),
or via an inline JSON-Schema dict literal) binds the type parameter.
A user-defined wrapper such as
fn grade<T>(prompt: string, schema: Schema<T>) -> T {
const r = llm_call(prompt, nil,
{provider: "mock", output: {schema: schema, validation: "error"}})
return r.data
}
const out: GraderOut = grade("Grade this", schema_of(GraderOut))
log(out.verdict)
narrows out to GraderOut at the call site without any
schema_is / schema_expect guard, and without per-wrapper
typechecker support.
Schema<T> is a type-level construct. In value positions, the
runtime schema_of(T) builtin returns an idiomatic schema dict
whose static type is Schema<T>.
Human-in-the-loop primitives
Human-in-the-loop is modeled as first-class typed expression syntax.
ask_user, request_approval, dual_control, and escalate_to are
reserved keywords with VM-enforced semantics: their names cannot be
shadowed or rebound, the result envelopes are produced (and signed) by
the runtime, and quorum approval requires distinct principals. Each
primitive accepts either named arguments or the legacy positional form;
both lower to the same runtime.
const answer = ask_user(prompt: "deploy now?", schema: schema_of(Choice))
const record = request_approval(action: "merge_pr", quorum: 2,
reviewers: ["alice", "bob", "carol"])
const merged = dual_control(n: 2, m: 3, action: destructive_step,
approvers: ["alice", "bob", "carol"])
const handle = escalate_to(role: "oncall", reason: "deploy failed")
The runtime owns blocking semantics, timeout behavior, event-log records, and replay.
ask_user<T>(prompt: string, options?: {schema?: Schema<T>, timeout?: duration, default?: T}) -> Trequest_approval(action: string, options?: ApprovalRequestOptions)returns{approved: bool, reviewers: list<string>, approved_at: string, reason: string | nil, signatures: list<{reviewer: string, signed_at: string, signature: string}>}.ApprovalRequestOptionsis{detail?: any, args?: any, quorum?: int, reviewers?: list<string>, deadline?: duration, principal?: string, evidence_refs?: list<dict>, undo_metadata?: dict, capabilities_requested?: list<string>}.dual_control<T>(n: int, m: int, action: fn() -> T, approvers?: list<string>) -> Tescalate_to(role: string, reason: string)returns{request_id: string, role: string, reason: string, trace_id: string, status: string, accepted_at: string | nil, reviewer: string | nil}.hitl_pending(filters?: {since?: string, until?: string, kinds?: list<string>, agent?: string, limit?: int})returnslist<{request_id: string, request_kind: string, agent: string, prompt: string, trace_id: string, timestamp: string, approvers: list<string>, metadata: dict}>.
Normative behavior:
ask_userappendshitl.question_asked, then blocks until the host appends a matching response. The default timeout is 24 hours unlesstimeoutis supplied. Ifschemais present, the answer must satisfy it. If the wait times out, Harn appendshitl.timeoutand either returnsoptions.defaultor throwsHumanTimeoutError.request_approvalappendshitl.approval_requestedand waits for the configured quorum.deadlinedefaults to 24 hours. Denial raisesApprovalDeniedError. Successful completion returns the approval record, including one signed reviewer timestamp receipt per counted approver.dual_controlis an approval-gated wrapper around a closure. The closure is not executed until quorum is satisfied. The runtime appendshitl.dual_control_requested,hitl.dual_control_approved/hitl.dual_control_denied, andhitl.dual_control_executed.escalate_toappendshitl.escalation_issuedand blocks until the host appendshitl.escalation_accepted. The request payload includes the active capability policy when one is installed so hosts can resolve the requested role against the same capability ceiling enforced by the VM. If the host does not respond, the dispatch remains paused until manual resume.hitl_pendingreads the durable HITL topics via the active event log, returns[]when no event log is attached, filters bysince/until/kinds/agent/limit, and omits requests that have already reached a terminal HITL event.
HITL records live in durable event-log topics:
hitl.questionshitl.approvalshitl.dual_controlhitl.escalations
Replay is event-log-driven. During replay, HITL primitives resolve from the previously recorded HITL response events instead of consulting a live host, so approval reviewer identities, signed timestamps, and signatures remain stable across deterministic replay.
Replay-for-teaching corrections live in corrections.records. std/corrections
accepts CorrectionInput, whose from_decision and to_decision fields use
the reusable CorrectionDecision shape and whose optional evidence uses
list<CorrectionEvidenceRef>. The stored CorrectionRecord captures those
decisions plus { reason, applied_by, scope }, with optional
actor/action/trace/step metadata. this_persona and all scopes feed
CapabilityPolicy derivation by tightening the affected actor to a read-only
side-effect ceiling while matching correction records remain applicable.
Function type annotations
Parameters and return types can be annotated:
fn add(a: int, b: int) -> int {
return a + b
}
Type checking behavior
- Annotations are optional (gradual typing). Untyped values are
Noneand skip checks. intis assignable tofloat.- Dict literals with string keys infer a structural shape type.
- Dict literals with computed keys infer as generic
dict. - Shape-to-shape: all required fields in the expected type must exist with compatible types.
- Option-bag literal calls reject keys that are not declared by the expected option shape.
- Shape-to-
dict<K, V>: all field values must be compatible withV. - Type errors are reported at compile time and halt execution.
Flow-sensitive type refinement
The type checker performs flow-sensitive type refinement (narrowing) on union types based on control flow conditions. Refinements are bidirectional — both the truthy and falsy paths of a condition are narrowed.
Nil checks
x != nil narrows to non-nil in the then-branch and to nil in the
else-branch. x == nil applies the inverse.
fn greet(name: string | nil) -> string {
if name != nil {
// name is `string` here
return "hello ${name}"
}
// name is `nil` here
return "hello stranger"
}
Non-null assertion (expr!)
When you know a value is non-nil but the type system cannot prove it — an
optional field you have already validated, or an index read established as
in-bounds by an earlier guard — the postfix ! operator asserts it. Statically
it strips the nil arm from the operand's type (T | nil -> T):
fn label(cfg: {name: string?}) -> string {
// The caller guarantees `name` is set for this code path.
return cfg.name! // `cfg.name` is `string?`; `!` recovers `string`
}
At runtime expr! is identity when the value is present and throws a catchable
unwrap_nil error when it is nil — unlike ?? default, which supplies a
fallback, ! fails loudly. Prefer a != nil guard, a ?? default, or a
for-loop where one applies; reach for ! only when the invariant is real but
not expressible. Asserting a value that is already non-nil is reported as
unnecessary (HARN-LNT-063).
type_of() checks
type_of(x) == "typename" narrows to that type in the then-branch and
removes it from the union in the else-branch.
fn describe(x: string | int) {
if type_of(x) == "string" {
log(x) // x is `string`
} else {
log(x) // x is `int`
}
}
Reference paths
Narrowing applies to reference paths — an identifier followed by a
chain of constant property accesses and constant subscripts
(entry.arguments, cfg.opts.mode, xs[0], m["k"]) — not just bare
variables. Every refinement form that narrows a variable also narrows a
path: type_of(path) == "T", path != nil, a bare if path
(truthiness, removes nil), schema_is(path, S) / path.has("k"), and
a tagged-shape-union discriminant (o.msg.kind == "ping" narrows
o.msg). A guard on a path narrows later reads of that same path:
fn flags(entry: {arguments: list?}) -> list {
if type_of(entry.arguments) == "list" {
return entry.arguments // entry.arguments is `list` here
}
return []
}
Optional (?.) and plain (.) links address the same value, so they
share a narrowing — type_of(entry?.arguments) == "list" narrows reads
of both entry?.arguments and entry.arguments. A path whose type is
the top type (unknown/any, common for json_parse / llm_call
boundary fields) narrows to the tested kind, exactly as an
unknown-typed variable does.
The narrowing is dropped when the base variable or the path is
reassigned, since the reference may then point at a different value. A
dynamic subscript (xs[i] with a non-literal index) is deliberately
never narrowed: it is not a stable reference, so a later xs[i] may
read a different element.
Index reads are optional
An index read into a list<T>, a dict<K, V>, or a string yields
T | nil (respectively V | nil and string | nil): an out-of-bounds
index or an absent key is nil at runtime, so typing the read as a bare
T would be unsound. This mirrors TypeScript's noUncheckedIndexedAccess.
const xs: list<int> = []
const n: int = xs[0] // error: expected int, found int? (xs[0] may be nil)
Recover the non-nil element in one of three ways:
?? default— coalesce the absent case:const n: int = xs[0] ?? 0.- A
for x in xsloop — the loop variable binds the element typeTdirectly, neverT?, because iteration never visits an absent slot. .first()/.last()— these accessors already returnT?.
An honest element accessor therefore returns the optional element type:
fn first<T>(xs: list<T>) -> T? { return xs[0] }. Index writes
(xs[i] = v) are unaffected: the assigned slot keeps its bare element
type T, since a write stores a present value.
Truthiness
A bare identifier in condition position narrows by removing nil:
fn check(x: string | nil) {
if x {
log(x) // x is `string`
}
}
Logical operators
a && b: combines both refinements on the truthy path.a || b: combines both refinements on the falsy path.!cond: inverts truthy and falsy refinements.
fn check(x: string | int | nil) {
if x != nil && type_of(x) == "string" {
log(x) // x is `string`
}
}
Guard statements
After a guard statement, the truthy refinements apply to the outer
scope (since the else-body must exit):
fn process(x: string | nil) {
guard x != nil else { return }
log(x) // x is `string` here
}
Early-exit narrowing
When one branch of an if/else definitely exits (via return,
throw, break, or continue), the opposite refinements apply after
the if:
fn process(x: string | nil) {
if x == nil { return }
log(x) // x is `string` — the nil path returned
}
While loops
The condition's truthy refinements apply inside the loop body.
Ternary and if expressions
The condition's refinements apply to the true and false branches
respectively — for both the cond ? a : b ternary and an if/else
used as an expression for its value:
fn pick(x: string | int) -> string {
// then-branch sees `x: string`, so the result type is `string`.
return if type_of(x) == "string" { x } else { "fallback" }
}
Match expressions
When matching a union-typed variable against literal patterns, the variable's type is narrowed in each arm:
fn check(x: string | int) {
match x {
"hello" -> { log(x) } // x is `string`
42 -> { log(x) } // x is `int`
_ -> {}
}
}
Matching on type_of(subject) narrows the subject (variable or
reference path) in each arm to the tested kind — the match
counterpart of an if type_of(subject) == "T" chain:
fn describe(o: {val: string | int}) -> string {
return match type_of(o.val) {
"string" -> { o.val } // o.val is `string`
"int" -> { to_string(o.val) } // o.val is `int`
_ -> { "?" }
}
}
Or-patterns (pat1 | pat2 -> body)
A match arm may list two or more alternative patterns separated by |;
the shared body runs when any alternative matches. Each alternative
contributes to exhaustiveness coverage independently, so an or-pattern
and a single-literal arm compose naturally:
fn verdict(v: "pass" | "fail" | "unclear") -> string {
return match v {
"pass" -> { "ok" }
"fail" | "unclear" -> { "not ok" }
}
}
Narrowing inside the or-arm refines the matched variable to the union of the alternatives' single-literal narrowings. On a literal union this is a sub-union; on a tagged shape union it is a union of the matching shape variants:
type Msg =
{kind: "ping", ttl: int} |
{kind: "pong", latency_ms: int} |
{kind: "close", reason: string}
fn summarise(m: Msg) -> string {
return match m.kind {
"ping" | "pong" -> {
// m is narrowed to {kind:"ping",…} | {kind:"pong",…};
// the shared `kind` discriminant stays accessible.
"live:" + m.kind
}
"close" -> { "closed:" + m.reason }
}
}
Guards apply to the arm as a whole: 1 | 2 | 3 if n > 2 -> … runs the
body only when some alternative matched and the guard held. A guard
failure falls through to the next arm, exactly like a literal-pattern
arm.
Or-patterns are restricted to literal alternatives (string, int, float, bool, nil) in this release. Alternatives that introduce identifier bindings or destructuring patterns are a forward-compatible extension and currently rejected.
.has() on shapes
dict.has("key") narrows optional shape fields to required:
fn check(x: {name?: string, age: int}) {
if x.has("name") {
log(x) // x.name is now required (non-optional)
}
}
Exhaustiveness checking with unreachable()
The unreachable() builtin acts as a static exhaustiveness assertion.
When called with a variable argument, the type checker verifies that the
variable has been narrowed to never — meaning all possible types have
been handled. If not, a compile-time error reports the remaining types.
fn process(x: string | int | nil) -> string {
if type_of(x) == "string" { return "string: ${x}" }
if type_of(x) == "int" { return "int: ${x}" }
if x == nil { return "nil" }
unreachable(x) // compile-time verified: x is `never` here
}
At runtime, unreachable() throws "unreachable code was reached" as a
safety net. When called without arguments or with a non-variable argument,
no compile-time check is performed.
Exhaustive narrowing on unknown
The checker tracks the set of concrete type_of variants that have been
ruled out on the current flow path for every unknown-typed variable.
The falsy branch of type_of(v) == "T" still leaves v typed unknown
(subtracting one concrete type from an open top still leaves an open
top), but the coverage set for v gains "T".
When control flow reaches a never-returning site — unreachable(), a
throw statement, or a call to a user-defined function whose return
type is never — the checker verifies that the coverage set for every
still-unknown variable is either empty or complete. An incomplete
coverage set is treated as a failed exhaustiveness claim and triggers a
warning that names the uncovered concrete variants:
fn handle(v: unknown) -> string {
if type_of(v) == "string" { return "s:${v}" }
if type_of(v) == "int" { return "i:${v}" }
unreachable("unknown type_of variant")
// warning: `unreachable()` reached but `v: unknown` was not fully
// narrowed — uncovered concrete type(s): float, bool, nil, list,
// dict, closure, bytes
}
Covering all nine type_of variants (int, string, float, bool,
nil, list, dict, closure, bytes) silences the warning. Suppression via
an explicit fallthrough return is intentional: a plain return
doesn't claim exhaustiveness, so partial narrowing followed by a normal
return stays silent. Reaching throw or unreachable() with no prior
type_of narrowing also stays silent — the coverage set must be
non-empty for the warning to fire, which avoids false positives on
unrelated error paths.
Reassigning the variable clears its coverage set, matching the way narrowing is already invalidated on reassignment.
Unreachable code warnings
The type checker warns about code after statements that definitely exit
(via return, throw, break, or continue), including composite
exits where both branches of an if/else exit:
fn foo(x: bool) {
if x { return 1 } else { throw "err" }
log("never reached") // warning: unreachable code
}
Reassignment invalidation
When a narrowed variable is reassigned, the narrowing is invalidated and the original declared type is restored.
Mutability
Variables declared with let are immutable. Assigning to a let
variable produces a compile-time warning (and a runtime error).
Runtime parameter type enforcement
In addition to compile-time checking, function parameters with type annotations
are enforced at runtime. When a function is called, the VM verifies that each
annotated parameter matches its declared type before executing the function body.
If the types do not match, a TypeError is thrown:
TypeError: parameter 'name' expected string, got int (42)
The following types are enforced at runtime: int, float, string, bytes,
bool, list, dict, set, nil, and closure. int and float are mutually
compatible (passing an int to a float parameter is allowed, and vice versa).
Union types, list<T>, dict<string, V>, and nested shapes are also checked at
runtime when the parameter annotation can be lowered into a runtime schema.
Runtime shape validation
Shape-annotated function parameters are validated at runtime. When a function
parameter has a structural type annotation (e.g., {name: string, age: int}),
the VM checks that the argument is a dict (or struct instance) with all
required fields and that each field has the expected type.
fn process(user: {name: string, age: int}) {
log("${user.name} is ${user.age}")
}
process({name: "Alice", age: 30}) // OK
process({name: "Alice"}) // Error: parameter 'user': missing field 'age' (int)
process({name: "Alice", age: "old"}) // Error: parameter 'user': field 'age' expected int, got string
Shape validation works with both plain dicts and struct instances. Extra
fields are allowed (width subtyping). Optional fields (declared with ?)
are not required to be present.