Language
Type System
The type of a schema field does three jobs at once: it produces the JSON Schema sent to the gateway, it validates the response locally, and it becomes a TypeScript declaration through --generate-tool-types. This page lists every type and its JSON Schema counterpart.
Primitive types
| Type | Alias | Accepted value | JSON Schema |
|---|---|---|---|
string | — | JS string | { "type": "string" } |
int | integer | A value that passes Number.isInteger | { "type": "integer" } |
float | number | A finite number — NaN and Infinity are rejected | { "type": "number" } |
bool | boolean | true / false | { "type": "boolean" } |
null | — | Only null | { "type": "null" } |
any | — | Anything — no validation is performed | {} |
If 3.5 arrives in an int field you get X.y must be an integer.; 3.0 is valid as far as Number.isInteger is concerned. If NaN or Infinity arrives in a float field you get X.y must be a number. — which catches malformed model output early.
Composite types
enum
status: enum["pending", "approved", "rejected"]
Accepts string values only and cannot be empty. The JSON Schema counterpart is { "type": "string", "enum": [...] }. A value outside the list produces X.y must be one of these values: ….
Array
tags: string[] // unbounded list of strings items: LineItem[] // list of nested schemas grid: int[][] // nested array
JSON Schema: { "type": "array", "items": { … } }. Each element is validated separately and the index appears in the error message: Invoice.items[2].amount missing.
An element is reached with [] — rows[0], rows[i].customer.name — and the same form on the left of an = writes one. Reading past the end is a named error, never a quiet null: xs[5] is out of bounds (length 2). Writing past the end is refused too, because a list does not grow by being indexed. The full table of boundary cases is on the language page.
map<T> / record<T>
metrics: map<float> // { "score": 0.95, "confidence": 0.8 }
labels: record<string> // record is an alias for mapAn object with dynamic keys. JSON Schema: { "type": "object", "additionalProperties": { … } }. Keys are free; the type of the values is validated.
A value is reached with m["key"], and writing to a key that is not there creates it. Reading one that is not there does not return null — a missing key is a named error: m["missing"] is not present. That is a statement about the type and not merely about the runtime: map<T> holds T, not T | null, so a lookup is not a place where null can enter the program. A key you are not sure about has to be checked before it is read:
import col from "std/collections"
agent Reader {
fn read(labels: map<string>, key: string) -> Out {
if contains(col.keys(labels), key) {
return { value: labels[key] }
}
return { value: "unknown" }
}
}The full table of indexing boundary cases — lists as well as maps, reads as well as writes — is on the language page.
Strict structured-output mode (strict: true) makes most gateways expect every object to list all of its keys under properties and to set additionalProperties: false. A map<T> with dynamic keys cannot satisfy that, and the provider may reject the request.
If you need to carry dynamic metadata as LLM output, use a list of key/value pairs — that pattern is safe in strict mode:
schema MetaEntry {
key: string
value: string
}
// inside InvoiceExtract:
meta: MetaEntry[] @desc("Anything that does not fit the schema fields")You can use map<T> freely in deterministic code (tool output, a value produced by return); the restriction only applies to schemas sent to the model.
json<T>
import json from "std/json"
schema Envelope {
raw: string // JSON text
data: any // parsed, unvalidated value
payload: json<Payload> // parsed AND validated against Payload
}The json<T> type validates the value against T. If you want to hold JSON text, the type must be string. In JSON Schema generation json<T> unfolds directly into T, so as far as the gateway is concerned it is identical to T.
Tuple
coordinate: [float, float] // exactly two elements, positional
A fixed-length, positionally typed array. Validation checks the element count and the type at each position. It must contain at least one comma ([float] is not a tuple).
A tuple is emitted as prefixItems in JSON Schema and works fine as ask output. Numeric and string annotations cannot target tuple positions, though; the parser rejects them:
Error: @range can only be used on numeric fields or numeric arrays.
When you need a constraint per position, named fields are both more readable and more expressive:
schema Point {
lat: float [-90..90]
lon: float [-180..180]
}Schema reference
schema Address {
city: string
}
schema Customer {
address: Address // nested object
history: Address[] // list of nested objects
}Emitted as { "$ref": "#/$defs/Address" } in JSON Schema; the definition appears once under $defs.
If a field's type is a direct schema reference, the @desc on that field is not carried into JSON Schema. The reason is that some gateways reject a request when they see description at the same level as $ref. Put the description on the target schema itself (schema Address @desc("…")) or on the fields inside it.
Parametric schema — Name<T>
schema LineItem {
sku: string
amount: float
}
schema Page<T> {
items: T[]
total: int
}
schema Report {
page: Page<LineItem>
}A schema may declare type parameters. Inside the body the parameter reads like any other type name, and every use site supplies a concrete argument: Page<LineItem>, Page<Customer>.
Page<LineItem> is expanded while the file is parsed. The parser copies the body of Page, puts LineItem where T stood, and registers the result as an ordinary schema named Page_LineItem. Nothing past that point knows a parameter was ever involved — JSON Schema generation, validation, $defs, where, when, @desc and std/schema reflection all run on the expanded schema exactly as they would on one typed out by hand, and no genericity reaches the provider.
The expanded name is the name you see. It is the $defs key, it is what schema.name(…) returns, and it is the prefix of a validation error raised against that schema:
Page_LineItem.items[0].amount missing.
Every position that accepts a schema name
| Position | Written as |
|---|---|
| Method return type | fn list() -> Page<LineItem> |
| Schema field | page: Page<LineItem> |
| Agent method parameter | fn take(page: Page<LineItem>) -> Report |
pure fn / io fn parameter | pure fn count(page: Page<LineItem>) -> int |
| Tool parameter | tool inspect(page: Page<LineItem>) -> Box<int> |
| Typed local variable | local: Page<LineItem> = page |
The carrying case is the one that matters: a producing method returns Page<LineItem> and a consuming method takes the same thing as a parameter, so the wrapper never has to be unpacked by hand at the boundary between them.
Several parameters, nesting, primitives
schema LineItem {
sku: string
}
schema Customer {
name: string
}
schema Page<T> {
items: T[]
}
schema Pair<A, B> {
left: A
right: B
}
schema Box<T> {
value: T
}
schema Envelope<T> {
payload: T
trace_id: string
}
schema Deep<T> {
inner: Box<T>
}
schema Catalogue {
pair: Pair<LineItem, Customer>
pages: Page<Envelope<LineItem>>
count: Box<int>
deep: Deep<LineItem>
}A type argument may be a primitive (Box<int>), a schema, or another instantiation (Page<Envelope<LineItem>>), and a generic body may reference a second generic — Deep<T> above holds a Box<T>. Each distinct instantiation is expanded once, under a name assembled from its arguments: Pair_LineItem_Customer, Page_Envelope_LineItem, Box_int, Deep_LineItem. Give a generic the wrong number of arguments and the count is named back at you: Generic schema 'Pair' expects 2 type argument(s), got 1.
What a parameter may stand for, and what may be passed to it
Both sides of the substitution are wider than the examples above suggest. A parameter is not restricted to a bare field: it substitutes into every composite position, so one declaration covers the array, the map, the nullable, the tuple and the raw-JSON form of the same type.
schema LineItem {
sku: string
}
schema Multi<T> {
one: T
many: T[]
lookup: map<T>
maybe: T | null
pair: [T, string]
raw: json<T>
}
schema Catalogue {
rows: Multi<LineItem>
}And the argument does not have to be a schema name. Anything the type grammar can write may be passed, each one expanding under a name built from the argument's own written form:
| Written as | Expansion |
|---|---|
Box<string[]> | Box_string_array |
Box<map<int>> | Box_map_int |
Box<string | null> | Box_union_string_or_null |
Box<enum["red", "green"]> | Box_enum_red_green |
Box<json<any>> | Box_json_any |
Box<Box<Box<LineItem>>> | Box_Box_Box_LineItem |
Type parameters are a schema construct only. Writing them on any other block does not give you a generic agent or a generic function.
Composition and generics used to be resolved before imports were merged, so both stopped at the file they were written in. A multi-module package could not share a base schema — schema Admin = User + Audit failed with Unknown schema composition part: User when User came from another file, and an imported Page<T> could not be instantiated in a field type. Resolution is now deferred until after the merge, so all three forms cross the boundary:
import "./base.luma" // Taban, Ek, Zarf<T>
schema Birlesik = Taban + Ek // composition
schema Kullanim = Zarf<Ic> // generic alias
schema Sonuc { kutu: Zarf<Ic> } // generic field typeThe alias form is not just newly cross-module — it did not exist in the language at all. schema K = Zarf<Ic> was silently swallowed: the line parsed as nothing, K was never registered, and the failure only surfaced later as No schema named 'K' was found. A schema X = … line that matches no known form is now refused by name:
Error: Schema definition 'schema K = …' matches no known form: use 'A + B' composition, 'A | B tagged by field' union, or a 'Template<Args>' generic alias.
Deferral covers field types and aliases. It does not cover everything, and the edges were measured rather than assumed:
- A generic in a parameter position — an agent method or
pure fnparameter — is still resolved eagerly, so an imported template cannot be used there. - A pending generic alias cannot be a composition part (
Unknown schema composition part). If you need that chain, instantiate the alias in its own module. - A template reached through a namespaced import (
mod.Zarf<Ic>) is out of scope — namespaced schema naming is a separate surface and was not measured. - Two modules exporting a template under the same name are refused with
Import generic schema conflict. A diamond — the same module reached twice — is a no-op, as it is for schemas.
Expansions are cached per instantiation, not per use site. Writing Page<LineItem> as a parameter and as the return type of the same method still produces a single Page_LineItem — the second use resolves to the schema the first one registered. That is what keeps $defs from growing with every mention of a wrapper.
Each of these stops the program before it runs:
Error: Unknown type argument schema 'Missing' for generic 'Page'. Error: Generic schema cycle found: Tree<int> -> Tree<int>. Error: Tagged unions cannot use generic schema members: Document. Error: Tagged union 'Document' must contain at least two members.
- A type argument that names nothing —
Page<Missing>. Arguments are resolved, not taken on trust. - A generic that instantiates itself — a
Tree<T>holdingchildren: Tree<T>[]. Expansion has no stopping point there, so the cycle is reported with the chain that produced it rather than run forever. - A generic used as a tagged union member —
schema Document = Page<Invoice> | Page<Receipt> tagged by kind. The tag carries a member's schema name, and which name an expansion should contribute was left undecided instead of guessed. The reverse direction is fine: a tagged union is a perfectly good type argument, andEnvelope<Document>expands like any other. - And the rule underneath that one: a tagged union needs at least two members, so
schema Document = Invoice tagged by kindis refused as well. A sum type with a single branch is a rename.
Reflection takes a value, and Page on its own is not one, so schema.toJsonSchema(Page<LineItem>) never runs:
Error: 'Page' variable is not defined yet.
Reach the expansion instead. Reflect over a schema that holds the instantiation on a field, or name the expanded schema directly — schema.toJsonSchema(Page_LineItem) — which exists as soon as some position in the program has instantiated it. With no instantiation anywhere, the same message comes back with the expanded name in it.
Union and nullable
note: string | null value: int | float | null flexible: string | int
A union is written with | and produces anyOf in JSON Schema. During validation the members are tried in order; if none match, every sub-error is combined into one report:
Customer.tax_id does not match any union type: Customer.tax_id must be a string. | Customer.tax_id must be null.
Tagged union — tagged by
A | written inside a field type is an untagged union. A schema declared with = and tagged by is a different thing: a sum type. A value is one member or the other, and a tag field says which.
schema Invoice {
date: string
total: float
invoice_number?: string
}
schema Receipt {
date: string
total: float
reference?: string
}
schema Document = Invoice | Receipt tagged by kindThe declaration is a single line with no body, and it needs at least two members that are already-defined schemas — an unknown member stops the parser with Tagged union 'Document' member schema 'Voucher' was not found. From there the two forms are resolved by entirely different rules:
| Written as | How a value is matched |
|---|---|
A | B — inside a field type | Members are tried in order; the first one that validates wins. |
schema D = A | B tagged by k | Only the k tag is read, and it names exactly one member. Nothing is tried. |
Real schemas overlap. Invoice and Receipt above both carry date and total, and what separates them is optional — so a value holding only a date and a total validates cleanly against both. In an untagged doc: Invoice | Receipt field the first member wins, validation stops there and the run reports success. The value became an invoice because of the order the members were typed in.
Writing the same field as doc: Receipt | Invoice turns the very same value into a receipt. Nothing else in the program changes and no warning is produced. The tag removes the guess: under tagged by kind, a value carrying "kind": "Receipt" is a Receipt however well Invoice would have fitted.
The tag is injected, not declared
tagged by kind adds the kind field to every member, and its value is that member's schema name: "Invoice", "Receipt". You never write the field yourself, and the members are not altered — Invoice on its own, as a field type, a parameter or an ask output, still has exactly the fields it declares and no kind among them.
Because the tag is injected, a member that declares it too is a conflict, and the parser refuses the whole program:
Error: Tagged union 'Document' discriminator 'kind' conflicts with member schema 'Invoice'.
Reading the tag
The tag is read before anything else. It selects one member and the rest of the value is validated against that member alone, so the branch is deterministic even when every member would have fitted:
agent Router {
fn route(document: Document) -> Result {
match document {
case Invoice invoice { return { branch: "Invoice" } }
case Receipt receipt { return { branch: "Receipt" } }
}
}
}
router = Router()
out = router.route({ kind: "Receipt", date: "2026-02-01", total: 42.0 })A value whose tag is missing — or is not a string — never reaches a member, and neither does one whose tag names a schema outside the union:
Router.route.document.kind must identify a tagged union member. Router.route.document.kind has unknown tagged union member 'Voucher'.
The prefix is the path of the value being validated; here, the document parameter of Router.route.
Optional / nullable matrix
This table is the distinction most often confused in Luma:
| Written as | Field may be absent? | Value may be null? | In JSON Schema required |
|---|---|---|---|
x: string | ✗ no | ✗ no | ✓ yes |
x?: string | ✓ yes | ✗ no | ✗ no |
x: string | null | ✗ no | ✓ yes | ✓ yes |
x?: string | null | ✓ yes | ✓ yes | ✗ no |
x: string @default("a") | ✓ (filled in when absent) | ✗ no | ✗ no |
When extracting data from a model, T | null is usually better than ?: the field is always present, the model gets an explicit way to say "I did not find it", and because it stays in the required list under strict schema mode the model cannot skip it. ?, by contrast, lets the model quietly swallow the field.
// The preferred pattern in invoice extraction:
invoice_number: string | null @desc("Invoice number")
due_date: string | null @desc("Payment due date")
items: InvoiceItem[] @desc("All line items; empty list if none")Arrays and null
| Written as | Meaning |
|---|---|
items: Item[] | Required array. An empty list [] is valid. |
items: Item[] @minItems(1) | At least one element required. |
items: Item[] | null | null or an array; the array may be empty. |
items?: Item[] | The field may be absent entirely. |
Collection annotations such as @minItems and @unique applied to a nullable array only run when the value really is an array; they are skipped when it is null.
Size and range shorthands
| Written as | Meaning |
|---|---|
float[0..1] | An array of 0–1 floats (bracket attached to the type) |
float [0..1] | A single float between 0 and 1 (space between type and bracket) |
These two behave differently in silence, and someone who writes confidence: float[0..1] has typically produced a schema that expects the model to return [0.82] instead of 0.82.
| Shorthand | Meaning | Equivalent to |
|---|---|---|
string[3] | An array of exactly 3 strings | string[] @minItems(3) @maxItems(3) |
LineItem[1..100] | Between 1 and 100 elements | LineItem[] @minItems(1) @maxItems(100) |
float [0..1] | 0 ≤ value ≤ 1 | float @range(0, 1) |
int [1..99] | 1 ≤ value ≤ 99 | int @range(1, 99) |
float[1..3] [0..100] | An array of 1–3 elements; each element between 0 and 100 | — |
schema Answer {
title: string
paragraphs: string[2] // exactly two paragraphs
people: Person[1..3] // 1-3 people
scores: float[1..3] [0..100]
confidence: float [0..1] // a single number between 0 and 1
}Writing score: float [0..100] @range(0, 50) makes the parser fail with Field range cannot be specified with both legacy syntax and @range. Pick one.
JSON Schema mapping
The schema below and the real JSON Schema it produces, taken from schema.toJsonSchema(...):
schema Address {
city: string
}
schema Customer @desc("Invoice customer") {
name: string @length(2, 40) @title("Full name") @example("Ada Byron")
tax_id: string | null @length(10) @desc("Tax identification number")
email?: string @fmt("email") @alias("e_mail")
score: float [0..100]
tags: string[] @minItems(1) @unique @contains("vip")
kind: enum["individual", "company"]
metrics: map<float>
address: Address
active: bool @default(true)
where score >= 0
when kind == "company" require [tax_id]
}{
"type": "object",
"description": "Invoice customer",
"properties": {
"name": { "type": "string", "minLength": 2, "maxLength": 40,
"examples": ["Ada Byron"], "title": "Full name" },
"tax_id": { "anyOf": [ { "type": "string", "minLength": 10, "maxLength": 10 },
{ "type": "null" } ],
"description": "Tax identification number" },
"email": { "type": "string", "format": "email",
"x-luma-aliases": ["e_mail"] },
"score": { "type": "number", "minimum": 0, "maximum": 100 },
"tags": { "type": "array", "items": { "type": "string" },
"minItems": 1, "uniqueItems": true,
"contains": { "const": "vip" } },
"kind": { "type": "string", "enum": ["individual", "company"] },
"metrics": { "type": "object", "additionalProperties": { "type": "number" } },
"address": { "$ref": "#/$defs/Address" },
"active": { "type": "boolean", "default": true }
},
"required": ["name", "tax_id", "score", "tags", "kind", "metrics", "address"],
"additionalProperties": false,
"allOf": [
{ "if": { "properties": { "kind": { "const": "company" } } },
"then": { "allOf": [ { "required": ["tax_id"] } ] } }
],
"x-luma-where": [ { "expression": "score >= 0" } ],
"x-luma-conditionals": [ … ]
}Points worth noting
- Given a single argument,
@length(10)sets bothminLengthandmaxLengthto 10 — that is, exactly 10 characters. - On a union field, annotations are applied to the matching branch: the length constraint goes to the
stringbranch and thenullbranch is left untouched. @defaultand?both keep the field out ofrequired.- The root object always carries
additionalProperties: false— the model cannot produce a field outside the schema. whereand conditional rules travel asx-luma-*extensions; gateways ignore them and Luma performs the validation locally.
A tagged union in JSON Schema
Each member is emitted in full inside anyOf, and the tag is pinned to a const in every one of them and joins that member's required list. That const is what does the discriminating: a document is a Receipt because its kind can only be "Receipt" there. This is the real dump for the Document union above:
{
"anyOf": [
{ "type": "object",
"properties": {
"date": { "type": "string" },
"total": { "type": "number" },
"invoice_number": { "type": "string" },
"kind": { "type": "string", "const": "Invoice" }
},
"required": ["date", "total", "kind"],
"additionalProperties": false },
{ "type": "object",
"properties": {
"date": { "type": "string" },
"total": { "type": "number" },
"reference": { "type": "string" },
"kind": { "type": "string", "const": "Receipt" }
},
"required": ["date", "total", "kind"],
"additionalProperties": false }
]
}Until v0.5.0 this surface emitted oneOf with a sibling discriminator. Strict json_schema mode rejects both, so a tagged union — the headline feature of v0.2.0 — could not reach a strict provider at all. The wire form moved to anyOf with the tag as a const, which carries the same information in a shape every provider accepts, and schema.toJsonSchema reports exactly what goes on the wire. → strict mode
The untagged form carries none of that. A doc: Invoice | Receipt field becomes a plain list of references, and nothing in the schema tells the gateway — or a validator — which member was meant:
"doc": { "anyOf": [ { "$ref": "#/$defs/Invoice" }, { "$ref": "#/$defs/Receipt" } ] }A parametric schema in JSON Schema
There is nothing special to see, and that is the whole point of expanding at parse time. Below is the real request body for an ask whose output type is Page<LineItem>; the model is handed a narrow object schema and never learns that a parameter was written:
{
"type": "object",
"properties": {
"items": { "type": "array", "items": { "$ref": "#/$defs/LineItem" } },
"total": { "type": "integer" }
},
"required": ["items", "total"],
"additionalProperties": false,
"$defs": {
"Page_LineItem": {
"type": "object",
"properties": {
"items": { "type": "array", "items": { "$ref": "#/$defs/LineItem" } },
"total": { "type": "integer" }
},
"required": ["items", "total"],
"additionalProperties": false
},
"LineItem": {
"type": "object",
"properties": {
"sku": { "type": "string" },
"amount": { "type": "number" }
},
"required": ["sku", "amount"],
"additionalProperties": false
}
}
}$defs holds only what this root schema reaches: the expansion itself and the LineItem it refers to. A Page<Customer> declared elsewhere in the same program, on a path no request touches, is absent from the body — an expansion is not a request cost until something reachable points at it. That is why parametric schemas were held back until $defs pruning existed: without pruning, one wrapper multiplied by every payload type would be paid for on every single call.
Complete mapping table
| Luma type | JSON Schema |
|---|---|
string | {"type":"string"} |
int | {"type":"integer"} |
float | {"type":"number"} |
bool | {"type":"boolean"} |
null | {"type":"null"} |
any | {} (no constraint) |
enum[…] | {"type":"string","enum":[…]} |
T[] | {"type":"array","items":T} |
map<T> | {"type":"object","additionalProperties":T} |
json<T> | Identical to T |
A | B | {"anyOf":[A, B]} |
schema D = A | B tagged by k | {"anyOf":[A, B]} — each member also gains k as a const, and that is what discriminates |
[A, B] (tuple) | {"type":"array","prefixItems":[A,B],"minItems":2,"maxItems":2} |
SchemaName | {"$ref":"#/$defs/SchemaName"} |
schema P<T> {…} used as P<X> | {"$ref":"#/$defs/P_X"} — the expansion sits under $defs as an ordinary object schema |
Which type when?
schema Record {
id: string
amount: float | null
status: enum["open", "closed"]
items: LineItem[]
meta: MetaEntry[]
}schema Record {
id: any // no validation
amount: float // no "not found" path
status: string // free text
items?: LineItem[] // the model may skip it
meta: map<any> // may be rejected in strict mode
}| Need | Choose | Why |
|---|---|---|
| A fixed set of values | enum[…] | The model cannot produce free text; an enum violation is caught locally. |
| A value that may be absent | T | null | The field stays required and gives the model an explicit "none" path. |
| Extra information outside the schema | Entry[] (key/value) | Compatible with strict gateways, unlike map<T>. |
| Carrying free-form JSON | any or json<T> | any does not validate; json<T> does. |
| A fixed-length pair of numbers | float[2] or a two-field schema | Tuples cannot carry per-position constraints. |
| A money or measurement value | float + where | Tie the consistency invariant to where. |
| An identifier or code | string + @match / @fmt | Enforce the shape at the schema level. |
Choosing a type for a query row
Since v0.4.0 the same vocabulary also types data that no model produced. A query returns Schema[] and validates it row by row; a parallel over validates each element result against its declared element schema. Both name the position that failed — Orders.row[0].amount must be a number., classified[2].band must be one of these values: … — so the table above still applies, but the reasoning behind it changes.
A model output type is a negotiation: you are shaping what the model is allowed to say, and every choice trades strictness against the model's ability to comply. A row type is a description: the shape is already fixed by the table, nothing is negotiating with you, and the only question is whether the declaration matches what the bridge hands back. Nothing is coerced on the way in — the declared type is checked against the JS value the driver produced — so a column that arrives as a Date does not satisfy string, and the fix belongs in the SQL or in the bridge rather than in a looser type.
| Need | Choose | Why |
|---|---|---|
A NOT NULL column | T | The table already guarantees it. A row that violates it is schema drift, and you want that reported rather than absorbed. |
| A nullable column | T | null | SQL NULL arrives as null and a bare T rejects the row. x?: T does not help here — it permits an absent key, and a row that came out of a select carries the column as a key. |
An aggregate (count, sum) | int / float, returned as a bare Schema | A return type without [] asserts exactly one row: query 'Totals' expected exactly one row, got 3. |
| A column whose type you have not pinned down | any, and only for now | any validates nothing, so the boundary stops naming the problem and the mismatch resurfaces later without a row index attached. Fine while exploring, a liability in a scheduled job. |