← Back to home

4. The type system

Every type position in the language: the eleven primitive scalars (plus the expression-only duration), the distinct money type that is precise in the column and a fixed-scale string on the wire, X id cross-aggregate references, collections and options, and the closed set of postfix generic carriers paged / envelope / option. Reach for it when you need to know what column a field becomes, why money serializes differently than decimal, or how a carrier projects onto each backend.

Grammar: TypeRef, TypeAtom, BaseType, PrimitiveType, IdType, NamedType, GenericCtor, DURATION (the timerSource literal) · Validators: type-system checks in src/language/type-system.ts; loom.bare-aggregate-in-type, loom.generic-position, loom.generic-arg-not-carrier, loom.union-*, loom.nullable-receiver, loom.duration-arity / loom.duration-arg-type, loom.file-field-needs-object-storage, loom.token-nullable, loom.list-find-removed · Docs: ../payloads.md, ../conformance-semantics.md (RS-12 / RS-24 — the numeric wire rules), ../resources.md (File storage)

A TypeRef is one BaseType head atom, followed by zero or more postfix carrier constructors, an optional [] array marker, an optional ? nullable marker, and an optional or-union tail — in that fixed order. Carriers and the array/optional markers bind tighter than or (string or int option ≡ string or (int option)). The head BaseType is one of: a PrimitiveType, an X id (IdType), a bare NamedType (enum / value object / part; an event or payload only in the transport positions that admit them), or the UI-only slot / action / capability-only Self id. All examples below are generated from one scratch system with a backend deployable per platform; output is excerpted.

Primitive scalars

Eleven primitives: int, long, decimal, money, string, bool, date, datetime, guid, json, File. Each maps to a host-language type, a wire (DTO) type, and a SQL column per backend. (duration is a type in the expression language — the result of days(3) or datetime - datetime — but not a declarable field type; see duration.) date is a calendar day and datetime an instant — see date.

Declined — there is no time (time-of-day) scalar. A time without a date has no defined ordering across a daylight-saving change, so time is refused with that reason (Unknown type 'time'. Loom has no time-of-day type …) rather than offered as a near miss. Use datetime for an instant, or date for a calendar day.

aggregate Order {
  qty: int
  bigQty: long
  rate: decimal
  total: money
  ref: string
  active: bool
  placedAt: datetime
  externalId: guid
  meta: json
}

The SQL column types (shared MigrationsIR, identical DDL on every backend with a database — every context lives in its own Postgres schema):

CREATE TABLE "orders"."orders" (
  "id" UUID NOT NULL,
  "qty" INTEGER NOT NULL,
  "big_qty" BIGINT NOT NULL,
  "rate" DECIMAL NOT NULL,
  "total" DECIMAL(19, 4) NOT NULL,
  "ref" TEXT NOT NULL,
  "active" BOOLEAN NOT NULL,
  "placed_at" TIMESTAMP WITH TIME ZONE NOT NULL,
  "external_id" UUID NOT NULL,
  "meta" JSONB NOT NULL,
  "version" INTEGER NOT NULL DEFAULT 1,        -- implicit optimistic-concurrency counter
  PRIMARY KEY ("id")
);

The host-language field/column declarations:

// db/schema.ts — Drizzle pg-core. money carries explicit precision (see below).
qty: integer("qty").notNull(),
bigQty: bigint("big_qty", { mode: "number" }).notNull(),
rate: numeric("rate").notNull(),
total: numeric("total", { precision: 19, scale: 4 }).notNull(),
ref: text("ref").notNull(),
active: boolean("active").notNull(),
placedAt: timestamp("placed_at", { withTimezone: true }).notNull(),
externalId: uuid("external_id").notNull(),
meta: jsonb("meta").notNull(),
// Domain/Orders/Order.cs
public int Qty { get; private set; } = default!;
public long BigQty { get; private set; } = default!;
public decimal Rate { get; private set; } = default!;
public decimal Total { get; private set; } = default!;
public string Ref { get; private set; } = default!;
public bool Active { get; private set; } = default!;
public DateTime PlacedAt { get; private set; } = default!;
public Guid ExternalId { get; private set; } = default!;
public System.Text.Json.JsonElement Meta { get; private set; } = default!;
// features/orders/Order.java — JPA entity
@Column(name = "qty")          int qty;
@Column(name = "big_qty")      long bigQty;
@Column(name = "rate")         BigDecimal rate;
@Column(name = "total")        BigDecimal total;
@Column(name = "ref")          String ref;
@Column(name = "active")       boolean active;
@Column(name = "placed_at")    Instant placedAt;
@Column(name = "external_id")  UUID externalId;
@Column(name = "meta")         JsonNode meta;
# app/db/schema.py — SQLAlchemy mapped columns
qty:         Mapped[int]      = mapped_column(Integer)
big_qty:     Mapped[int]      = mapped_column(BigInteger)
rate:        Mapped[Decimal]  = mapped_column(Numeric)
total:       Mapped[Decimal]  = mapped_column(Numeric(19, 4))
ref:         Mapped[str]      = mapped_column(Text)
active:      Mapped[bool]     = mapped_column(Boolean)
placed_at:   Mapped[datetime] = mapped_column(DateTime(timezone=True))
external_id: Mapped[str]      = mapped_column(Uuid(as_uuid=False))
meta:        Mapped[object]   = mapped_column(JSONB)
# app/domain/order.py holds rate as float, total as Decimal, meta as object
# lib/api_elixir/orders/order.ex — Ecto schema fields
field :qty,         :integer
field :big_qty,     :integer
field :rate,        :decimal
field :total,       :decimal
field :ref,         :string
field :active,      :boolean
field :placed_at,   :utc_datetime
field :external_id, Ecto.UUID
field :meta,        :map

json is an opaque blob — JSONB in SQL, never structurally expanded (z.unknown() / JsonElement / JsonNode / object / :map).

Numeric representation rules

The four numeric types follow one cross-cutting contract (docs/conformance-semantics.md RS-12 / RS-24; audited in numeric-types-audit-2026-08-23):

Type Column JSON wire Domain representation — node / dotnet / java / python / elixir
int INTEGER integer number / int / int / int / integer
long BIGINT integer JS number (bigint(…, { mode: "number" })) / long / long / int / integer
decimal DECIMAL number (float64, lossy by design) number / System.Decimal (→ double on the response) / BigDecimal (→ double on the response) / float (column Decimal) / %Decimal{} (→ Decimal.to_float on the response)
money DECIMAL(19, 4) string, always 4 decimals ("12.5000") decimal.js Decimal / decimal / BigDecimal / Decimal / %Decimal{}

A query-time projection's avg over a money column is money, not decimal — the mean of exact money is money, and it ships as the same fixed-scale string every other money field does (M-T5.24). avg over an integral or decimal column is a decimal mean, as before. The declared row field must carry the aggregation's own type; a mismatch is loom.projection-aggregate-type-mismatch rather than a silent re-coding, since the DECLARED type is what every backend's coercion and the response schema are built from.

The long ceiling. long persists as BIGINT, but its DECLARED contract is the safe-integer range ±(2^53−1) — the range every backend carries exactly, which the node backend's JS number representation is what bounds (D-LONG-AVG-DEFAULTS, src/util/numeric-range.ts). Enforced in two places, so a value outside it is refused rather than silently rounded:

An integral aggregate (sum(o.qty), count()) is range-checked the same way on every backend — sum(int) and count(*) are bigints in SQL, so the result routinely exceeds the declared row field's range, and a value that does not fit now fails the read on all five instead of wrapping (java), rounding (node) or shipping out of contract (elixir). Declare the row field long when a total can outgrow int32; that widening is admitted by loom.projection-aggregate-type-mismatch.

Past the ceiling the four non-node backends still ACCEPT an inbound value and store it exactly, so a value written through java and read back through node rounds; lifting node's representation (BigInt / string wire) is the named follow-up if the ceiling ever pinches.

Widening. int → long → decimal is implicit (isAssignable in type-system.ts); money is a closed type that never joins the chain. Integral division widens to decimal (5 / 2 is 2.5 on every backend, so derived half: int = a / b is a type error — declare it decimal or use a.divTrunc(b)); + - * % stay int-preserving. Money arithmetic admits only money ± money, money × {int|long|decimal} (commutative) and money ÷ {int|long|decimal} — total + rate with rate: decimal is rejected (Allowed for money: money ± money, money × {int|long|decimal}, money ÷ {int|long|decimal}). Bare numeric literals promote to the typed position they sit in (subtotal >= 0 with subtotal: money). The operator surface is Expressions.

money — precise column, string on the wire

money is not decimal. It is a distinct primitive with two guarantees: the column is DECIMAL(19, 4) (MONEY_WIRE_SCALE, src/generator/money-scale.ts), and the wire representation is a string at a fixed scale of 4 ("12.5", "12.50" and "12" all read back as "12.5000", rounded half-away-from-zero) — never a float, so no JSON-number rounding can corrupt an amount in transit. The matching literal form is money("10.50") (a STRING argument, never a DECIMAL); see Lexical structure. money carries no currency dimension; pair it with a Currency enum in a value object when you need one.

aggregate Order {
  rate: decimal
  total: money
}

The column pins precision 19, 4 where the ORM exposes it (the shared DDL always does):

// db/schema.ts — explicit precision/scale, unlike a bare `decimal`
total: numeric("total", { precision: 19, scale: 4 }).notNull(),
// Domain/Orders/Order.cs — precise host decimal; the column type comes from the shared migration
public decimal Total { get; private set; } = default!;
// features/orders/Order.java
@Column(name = "total") BigDecimal total;
# app/db/schema.py — Numeric(19, 4), unlike bare `Numeric` for decimal
total: Mapped[Decimal] = mapped_column(Numeric(19, 4))
# priv/repo/migrations/…_create_orders.exs
add :total, :decimal, precision: 19, scale: 4, null: false

The contrast that matters is the wire type. On the response DTO total is a string and rate a number on all five backends — each backend narrows its decimal to a double and fixes money to scale 4 at the response boundary:

// http/order.routes.ts — money field is z.string(), not z.number()
export const OrderResponse = z.object({
  rate: z.number(),     // decimal → number
  total: z.string(),    // money   → string
  // …
});
// db/repositories/order-repository.ts — toWire(): rate: root.rate, total: root.total.toFixed(4)
// Application/Orders/Responses/OrderResponses.cs
public sealed record OrderResponse(
    [property: Required] double Rate,    // decimal → double
    [property: Required] string Total,   // money   → string
    /* … */);
// GetOrderByIdHandler.cs projects it:
//   double.Parse(found.Rate.ToString(InvariantCulture), InvariantCulture),
//   found.Total.ToString("F4", InvariantCulture)
// features/orders/OrderResponse.java
public record OrderResponse(double rate, String total, /* … */) {
  public static OrderResponse from(Order value) {
    return new OrderResponse(
        value.rate().doubleValue(),                                            // decimal → double
        value.total().setScale(4, java.math.RoundingMode.HALF_UP).toPlainString(), // money → string
        /* … */);
  }
}
# app/http/order_routes.py
class OrderResponse(BaseModel):
    rate: float
    total: str

# app/db/wire.py — to_wire() calls money_str(root.total)
def money_str(amount: Decimal) -> str:
    return format(amount.quantize(Decimal("1e-4"), rounding="ROUND_HALF_UP"), "f")
# lib/api_elixir_web/api/schemas/order_response.ex
rate:  %OpenApiSpex.Schema{type: :number, format: :double},
total: %OpenApiSpex.Schema{type: :string, format: :decimal},

# lib/api_elixir_web/controllers/order_controller.ex — serialize/1
"rate"  => __decimal_num(record.rate),     # Decimal.to_float/1
"total" => __money_round(record.total),    # Decimal.round(dec, 4); Jason encodes a Decimal as a string

On the frontends a money value is displayed verbatim from the wire (moneyText in src/lib/format.* — no invented currency symbol, no 2-decimal truncation), and money form inputs submit the string. A decimal field, by contrast, is a JSON number in both directions.

Both halves are normative conformance rules, not emitter happenstance: money's fixed scale is RS-12, and "a plain decimal is a JSON number, and the same number every other backend sends" is RS-24 — which also records why the request direction deliberately stays on the wide type (a double request field turns an out-of-range 400 into a conversion 500). Payloads frames the same split as the choice it is when you are declaring a payload field.

date — a calendar day

date is a CALENDAR date — year, month, day; no time-of-day and no offset. Use it for a birth date, a due date, a policy's start: a value that is "January 1" for every reader. A datetime written 2026-01-01T00:00:00Z is December 31 for every principal west of UTC, and every backend, the wire and the column agree with each other about that — which is why it cannot be caught by comparing backends; date removes the instant entirely.

Wire ISO-8601 calendar date string, "2026-01-01" (OpenAPI format: date). A request value that is not exactly a calendar date ("2026-02-30", "2026-01-01T00:00Z") is a 422.
Column Postgres DATE on every backend.
Domain node string (the ISO text — orders correctly as a string) · dotnet DateOnly · java java.time.LocalDate · python datetime.date (imported as CalendarDate, so a field named date cannot shadow it) · elixir %Date{} (:date).
Frontends the ISO text; the form field is a date picker (<input type="date">, Flutter's showDatePicker), never a datetime one.

The operators are the comparisons (==, <, >=, …) between two dates. There is no date arithmetic and no date/datetime mixing — the v1 intrinsic set is deliberately small:

Expression Type Meaning
d.year() / d.month() / d.day() int the components (month and day are 1-based)
a.daysUntil(b) int signed whole days from a to b (b - a; negative when b is earlier)
t.toDate() date the UTC calendar date of a datetime
today() date the temporal prelude's now().toDate() — today in UTC
string(d) string the ISO YYYY-MM-DD text

None is queryable — a find … where compares a date column with a date parameter (this.birthDate < cutoff), which needs no intrinsic. A UI message formats one with the existing ICU spec, "Born {p.birthDate, date, ::yMMMd}"; the runtime renders it at local midnight of that day, so the day shown is the day stored in every zone.

aggregate Patient {
  birthDate: date
  nextVisit: date?
  derived birthYear: int = birthDate.year()
  derived daysToVisit: int = birthDate.daysUntil(nextVisit ?? birthDate)
  operation book(visit: date) {
    precondition birthDate <= today() message "A birth date cannot be in the future"
    nextVisit := visit
  }
}
repository Patients for Patient { }
criterion PatientsBornBefore(cutoff: date) of Patient = this.birthDate < cutoff
"birth_date" DATE NOT NULL,
"next_visit" DATE,
// db/schema.ts — `mode: "string"`: the default Date mode would build a JS Date at LOCAL midnight
birthDate: date("birth_date", { mode: "string" }).notNull(),
// http/patient.routes.ts — the request schema refuses a non-date as a 422
birthDate: z.string().date(),
// domain/patient.ts
get birthYear(): number { return Number((this._birthDate).slice(0, 4)); }
get daysToVisit(): number { return Math.round((Date.parse(this._nextVisit === null ? this._birthDate : this._nextVisit) - Date.parse(this._birthDate)) / 86400000); }
// Domain/Patients/Patient.cs — the wire DTO is the ISO string; the domain is DateOnly
public DateOnly BirthDate { get; private set; } = default!;
public int BirthYear => this.BirthDate.Year;
public int DaysToVisit => ((this.NextVisit == null ? this.BirthDate : this.NextVisit.Value).DayNumber - (this.BirthDate).DayNumber);
// features/patients/PatientService.java — a non-date is a pointed 422
var birthDate = WireFormatException.date(request.birthDate(), "/birthDate");
// features/patients/PatientResponse.java — LocalDate.toString() is ISO yyyy-MM-dd
value.birthDate().toString()
# app/domain/patient.py
from datetime import date as CalendarDate
def days_to_visit(self) -> int:
    return (((self._birth_date if self._next_visit is None else self._next_visit)) - (self._birth_date)).days
# lib/d/clinic/patient.ex — %Date{} structs compare through Date.compare/2
field :birth_date, :date
def days_to_visit(%__MODULE__{} = record), do: Date.diff((if is_nil(record.next_visit), do: record.birth_date, else: record.next_visit), record.birth_date)

Migrating a datetime field to date is a column type change (ALTER COLUMN … TYPE DATE USING col::DATE): it drops the time-of-day, and the cast reads each instant in the database session's zone. Like every type change it is destructive, so ddd migrate add refuses it without --allow-destructive.

The corpus fixture test/fixtures/corpus/date-scalar.ddd drives create / update / an operation parameter / a SQL comparison and the intrinsics on all five backends, asserting each date read back as the exact string written.

duration — expression-only

duration is the type of a temporal span. It has no field syntax (d: duration does not resolve) and no column; it exists so datetime arithmetic type-checks. It is produced by the intrinsics days(n) / hours(n) / minutes(n) (an int amount — days(1.5) is loom.duration-arg-type: Fractional spans are written in the finer unit ('hours(36)', not 'days(1.5)'); the wrong arity is loom.duration-arity) and by datetime - datetime. The closed rules (temporalArithmetic in type-system.ts): datetime ± duration → datetime, datetime - datetime → duration, duration ± duration → duration, duration × int → duration; everything else is rejected. Calendar-relative units (months, years) are deliberately absent — every unit is absolute. (The DURATION token — 15s, 5m, 1h — is a separate literal admitted only in a timerSource's every: clause; Channels.)

aggregate Order {
  placedAt: datetime
  derived dueAt: datetime = placedAt + days(3)
  derived overdue: bool = now() - placedAt > hours(48)
}
// domain/order.ts — milliseconds arithmetic on Date
get dueAt(): Date { return new Date((this._placedAt).getTime() + (((3) * 86400000))); }
get overdue(): boolean { return ((new Date()).getTime() - (this._placedAt).getTime()) > ((48) * 3600000); }
// Domain/Orders/Order.cs — TimeSpan
public DateTime DueAt => this.PlacedAt + TimeSpan.FromDays(3);
public bool Overdue => DateTime.UtcNow - this.PlacedAt > TimeSpan.FromHours(48);
// features/orders/Order.java — java.time.Duration
public Instant dueAt() { return this.placedAt.plus(Duration.ofDays(3)); }
public boolean overdue() { return Duration.between(this.placedAt, Instant.now()).compareTo(Duration.ofHours(48)) > 0; }
# app/domain/order.py — timedelta
def due_at(self) -> datetime:
    return self._placed_at + timedelta(days=(3))
def overdue(self) -> bool:
    return datetime.now(UTC) - self._placed_at > timedelta(hours=(48))
# lib/api_elixir_web/controllers/order_controller.ex — derived values are computed at serialize time
"dueAt" => DateTime.add(record.placed_at, ((3) * 86400000), :millisecond),

File — a stored object reference

File is a primitive modelled on json: a JSONB-backed, wire-only value with no expression semantics, whose wire shape is the fixed record FileRef = { url, key, contentType, size }. The bytes live in an object store — a deployable hosting a File-bearing aggregate must bind a kind: objectStore dataSource (storage blobs { type: localDisk } or s3), else loom.file-field-needs-object-storage. Every backend then serves POST /files (multipart → FileRef) and GET /files/{key}; the page-side FileUpload / FileLink primitives are in UI walker primitives.

aggregate Order {
  reference: string
  attachment: File
}
// system level:
//   storage blobs { type: localDisk }
//   resource ordersFiles { for: Orders, kind: objectStore, use: blobs }
//   deployable api { … dataSources: [ordersState, ordersFiles] }
// db/schema.ts
attachment: jsonb("attachment").notNull(),
// http/order.routes.ts — the FileRef wire record, expanded inline
attachment: z.object({ url: z.string(), key: z.string(), contentType: z.string(), size: z.number().int() }),
// Domain/Common — one shared record
public sealed record FileRef(string Url, string Key, string ContentType, long Size);
// Domain/Orders/Order.cs
public FileRef Attachment { get; private set; } = default!;
// features/orders/Order.java
FileRef attachment;
# app/domain/file_ref.py
class FileRef(TypedDict): ...
# app/db/schema.py
attachment: Mapped[FileRef] = mapped_column(JSONB)
# lib/api_elixir/orders/order.ex
field :attachment, :map

X id — cross-aggregate references

A reference to another aggregate is spelled Target id (IdType), never the bare aggregate name. The structural validator rejects a bare cross-aggregate type with loom.bare-aggregate-in-type (References across aggregate boundaries need an id link — write 'Customer id' (or 'Customer id[]' for many-to-many)) — a bare NamedType resolves only to enums, value objects, and entity parts in the same aggregate. X id lowers to the target's primary-key value type (always guid) and produces a real foreign key, an index, and, on the typed backends, a strongly-typed id wrapper.

aggregate Customer { name: string }

aggregate Order {
  customer: Customer id
}

The reference is a UUID column with an FK constraint and an index:

"customer" UUID NOT NULL,
FOREIGN KEY ("customer") REFERENCES "orders"."customers" ON DELETE RESTRICT
-- …
CREATE INDEX "orders_customer_idx" ON "orders"."orders" ("customer");

The host member is a wrapped id, not a raw Guid/UUID:

// db/schema.ts — plain uuid column; the id branding lives in the domain layer
customer: uuid("customer").notNull(),
// db/repositories/order-repository.ts rehydrates it: customer: Ids.CustomerId(root.customer)
// Domain/Orders/Order.cs — strongly-typed CustomerId
public CustomerId Customer { get; private set; } = default!;
// OrderConfiguration.cs — EF value converter to/from the raw Guid column
builder.Property(x => x.Customer).HasConversion(v => v.Value, v => new CustomerId(v)).HasColumnName("customer");
// features/orders/Order.java — @AttributeOverride maps the CustomerId record's
// `value` onto the `customer` column
@AttributeOverride(name = "value", column = @Column(name = "customer"))
CustomerId customer;
# app/domain/order.py holds the wrapped id; the row column is a plain uuid string
customer: CustomerId
# app/db/schema.py
customer: Mapped[str] = mapped_column(Uuid(as_uuid=False))
# lib/api_elixir/orders/order.ex — a plain :binary_id field …
field :customer, :binary_id
# … with the FK in the Ecto migration
add :customer, references(:customers, prefix: "orders", type: :uuid, on_delete: :restrict), null: false

On the wire the reference flattens to its id value (customer: z.string() / Guid Customer / UUID customer / customer: str / format: :uuid).

Collections — T[]

A trailing [] makes the field an array. For scalar/enum elements this is a native array column.

aggregate Order {
  tags: string[]
}
"tags" TEXT[] NOT NULL,
tags: text("tags").array().notNull(),   // db/schema.ts
// OrderResponse: tags: z.array(z.string())
public List<string> Tags { get; private set; } = default!;
// OrderResponse: IReadOnlyList<string> Tags
@JdbcTypeCode(SqlTypes.ARRAY)
@Column(name = "tags") List<String> tags;   // OrderResponse: List<String> tags
tags: Mapped[list[str]] = mapped_column(ARRAY(Text))   # OrderResponse: tags: list[str]
field :tags, {:array, :string}        # migration: add :tags, {:array, :text}, null: false

An array of references — X id[] — is different: enrichment derives a join-table association for it rather than an inline array column. See ../payloads.md and the association derivation in enrichment (phase ⑥). A contains … : Part[] is a child table, not an array (Domain modeling).

Where a collection of references or value objects can live

A collection of references (X id[]) or of value objects (Money[]) needs a table of its own, so it is accepted only where the schema gives it one:

Position X id[] <VO>[]
aggregate root field join table id-less child table
entity part field — refused (except under persistence: mikroorm, which folds it into jsonb) id-less child table
projection / workflow state field one jsonb id-array column — refused
inside a persisted value object — refused — refused

X id[]? is refused everywhere: a reference collection is a set, and the empty set already means "none". Each refusal is loom.collection-field-unpersisted:

aggregate Order {
  tags: Tag id[]?          // error: drop the '?'
  contains lines: Line[]
  entity Line { refs: Tag id[] }   // error: no join table for a part
}
loom.collection-field-unpersisted: aggregate 'Order' field 'tags' is an OPTIONAL reference collection ('X id[]?'). …

A projection or workflow state field refs: Order id[] is stored as one jsonb array of the ids:

"refs" JSONB NULL,

Options — T?

A trailing ? makes the field nullable: a NULL-able column and an optional/nullable host member and wire field. (T? is distinct from the option carrier below.) A token field may not be nullable (loom.token-nullable).

aggregate Order {
  notes: string?
}
"notes" TEXT NULL,
notes: text("notes"),                 // no .notNull()
// OrderResponse: notes: z.string().nullish()
public string? Notes { get; private set; }   // no `= default!`, no [Required]
@Column(name = "notes") String notes;         // nullable record component
notes: Mapped[str | None] = mapped_column(Text)   # OrderResponse: notes: str | None = None
field :notes, :string                 # migration: add :notes, :text, null: true

Nullable receivers. Reading any member of a T? value — a field, .length, an intrinsic or a collection op — is rejected until the value is narrowed (loom.nullable-receiver). Every backend emits a bare dereference (this._note.length on a string | null) that the generated project's own compiler refuses or that crashes at runtime. A null test narrows the path it tests (including a this. path) in the part of the expression it decides: a ternary's branch, and the later operands of an && (!= null) or || (== null) chain:

aggregate Order {
  notes: string?
  shipTo: Address?
  derived trimmed: string = notes.trim()                               // loom.nullable-receiver
  derived city:    string = shipTo.city                                // loom.nullable-receiver
  derived safe:    string = notes != null ? notes.trim() : ""          // OK — the ternary narrows
  derived size:    int    = this.notes != null ? this.notes.length : 0 // OK
  invariant notes == null || notes.length <= 200                      // OK — the rule skips an absent value
}

size on each backend:

get size(): number { return this._notes !== null ? [...this._notes].length : 0; }
public int Size => this.Notes != null ? this.Notes.EnumerateRunes().Count() : 0;
public int size() {
    return this.notes != null ? ((int) this.notes.codePoints().count()) : 0;
}
def size(self) -> int:
    return (len(self._notes) if self._notes is not None else 0)
"size" => (if not is_nil(record.notes), do: length(String.to_charlist(record.notes)), else: 0),

In a ui or component body a field read off a nullable record or value object is not a dereference: the frontends spell it null-safe (order.shipTo?.city), so a detail page reads an optional value object's leaves unguarded. Calls and scalar members there are refused like anywhere else.

'.trim()' can't be read on 'string?' — the receiver may be null and every backend emits a bare dereference. Guard it with a null-narrowing ternary. The catalogue checks (loom.intrinsic-unknown / -arity / -arg-type / -bare / -named-arg) run on the unwrapped type, so a typo on a nullable receiver is caught too. Numeric widening composes through the optional (int? → long?), and the null literal is assignable to any T?.

Generic carriers — paged, envelope, option

Three carrier-bounded generic payloads are built in, instantiated ML-postfix (the keyword follows its argument): T paged, T envelope, T option. A carrier may appear only in a transport position — a repository find return type, a queryHandler return type, or a payload field — never as a stored aggregate property (loom.generic-position: A generic carrier ('paged') is a transport shape). The argument must itself be a carrier (a primitive, an X id, an enum, a value object, or an aggregate, which projects through its <Agg>Wire); nesting two constructors (Order envelope paged) or a slot argument is rejected (loom.generic-arg-not-carrier). The pinned shapes — note that envelope is the odd one out: it is a read cardinality, not a wire wrapper (see § envelope):

paged(T)    → { items: T[]; page: int; pageSize: int; total: int; totalPages: int }   # 1-based
envelope(T) → T                                    # a SINGLE-ROW find: the bare body, 404 when absent
option(T)   → the 2-variant union  union[T, none]  (an untagged 200 / 404 as a find return)
repository OrderRepo for Order {
  find all(): Order paged
  find audit(): Order envelope
  find byRef(ref: string): Order option where this.ref == ref
}

A list-shaped find (Order paged, Order[]) other than find all is an error (loom.list-find-removed): a list read is a criterion passed to Repo.run(…) or a named retrieval — see Repositories & queries. A single-row reconstitution find (Order / Order? / Order option) stays first-class. The carrier shapes below are unchanged by where the query is declared.

paged

No backend serializes its framework-native paging type — each maps to the one DTO above, and every paged read auto-gains page / pageSize / sort / dir query controls (defaults 1 / 20 / id / asc, pageSize capped at 500).

// http/order.routes.ts
export const OrderPaged = z.object({
  items: z.array(OrderResponse), page: z.number().int(), pageSize: z.number().int(),
  total: z.number().int(), totalPages: z.number().int(),
}).openapi("OrderPaged");
// db/repositories/order-repository.ts — count() + limit/offset, totalPages = ceil(total / pageSize)
// Domain/Common — one shared record, reused by every paged find
public sealed record Paged<T>(IReadOnlyList<T> Items, int Page, int PageSize, int Total, int TotalPages);
// OrderRepository.Recent: CountAsync + OrderBy(EF.Property<object>(e, sortColumn)).Skip(offset).Take(pageSize)
// domain/common/Paged.java
public record Paged<T>(List<T> items, int page, int pageSize, int total, int totalPages) {}
// OrderRepositoryImpl.recent: jpa.recent(PageRequest.of(page - 1, pageSize, sort)) → new Paged<>(…)
# app/domain/paging.py
@dataclass(frozen=True)
class PagedResult[T]:
    items: list[T]; page: int; page_size: int; total: int; total_pages: int
# app/http/order_routes.py — OrderPaged(BaseModel): items / page / pageSize / total / totalPages
# lib/api_elixir/orders/order_repository.ex
def recent(page \\ 1, page_size \\ 20, sort \\ "id", dir \\ "asc") do
  query = from(record in ApiElixir.Orders.Order)
  total = Repo.aggregate(query, :count, :id)
  offset = (page - 1) * page_size
  # … order_by / limit(^page_size) / offset(^offset) / Repo.all() → {:ok, %{items:, page:, …}}
end

envelope

find audit(): Order envelope is a single-row find: the repository answers one Order, the route serialises the bare body at 200, and an empty result set is the not-found rung — 404 ProblemDetails. It is wire-identical to find audit(): Order, and that is the whole of its meaning. All five backends agree by construction (test/generator/envelope-carrier.test.ts asserts that T envelope and T emit byte-identically; test/fixtures/corpus/envelope.ddd is the fixture the compile gates read).

// db/repositories/order-repository.ts
async audit(): Promise<Order> {
  const rootRows = await this.db.select().from(schema.orders).limit(1);
  if (rootRows.length === 0) throw new AggregateNotFoundError("not_found");
  return Order._rehydrate({ … });
}
// http/order.routes.ts — 200 OrderResponse | 404, no wrapper
// Domain/Orders/IOrderRepository.cs
Task<Order> Audit(CancellationToken cancellationToken = default);
// Infrastructure — FirstOrDefaultAsync(…) ?? throw new AggregateNotFoundException("not_found")
// Api/OrdersController.cs — ActionResult<OrderResponse>
// features/orders/OrderRepository.java
Order audit();
// OrderService.audit(): found == null ? null : OrderResponse.from(found)
// OrdersController.auditOrder(): null → AggregateNotFoundException → 404
# app/db/repositories/order_repository.py
async def audit(self) -> Order | None:
    row = (await self._session.execute(select(OrderRow))).scalars().first()
    return None if row is None else await self._hydrate(row)
# app/http/order_routes.py — response_model=OrderResponse, 404 when absent
# lib/api_elixir/orders/order_repository.ex
@spec audit() :: {:ok, ApiElixir.Orders.Order.t() | nil} | {:error, term()}
def audit(), do: {:ok, Repo.one(from(record in ApiElixir.Orders.Order))}
# controller: {:ok, nil} → 404 ProblemDetails; {:ok, record} → json(conn, serialize(record))

What changed, and why the keyword now carries no distinct meaning. This section used to pin envelope(T) → { id, ts, body } and flag an "honest gap": the repository layer knew the carrier while no backend projected the wrapper onto the route. The gap was worse than honest. java did not compile — it named Envelope<Order> in the repository port, the Spring Data interface and the impl and declared the type nowhere (no class, no import), then called OrderResponse.from(…) on it; a @Query(…) Envelope<Order> audit(); is not a Spring-Data-mappable return either. dotnet did not compile — it declared record Envelope<T>(string Id, DateTime Ts, T Body) and then returned a bare Order from a Task<Envelope<Order>> (CS0029). elixir answered a JSON array of every row while its own published OpenAPI declared a single object. Only node and python were coherent.

The { id, ts, body } wrapper could not be made to ship: nothing in the IR can source ts. So the carrier was ratified as the single-row find node and python already implemented (M-T6.57), the wrapper record was deleted from the .NET emission, and envelope is unwrapped to its argument at each backend's find-return seam. envelope is retained as documentation-in-the-signature — "this read yields at most one row" — not as a wire shape.

Nothing caught this for as long as it did because no .ddd in the repository instantiated the carrier, so every compile gate was blind to it by construction. test/fixtures/corpus/envelope.ddd is the fix for that half.

option

T option is sugar for the 2-variant union union[T, none] and flows through the same machinery as A or B. Where that union lands decides its wire: as a payload field or an operation return it is the tagged { "type": "Order", … } / { "type": "none" } shape (Payloads & unions); as a find return it takes the untagged absence path — the success variant is returned directly at 200 and none is a 404 ProblemDetails, wire-identical to Order?. All five backends agree by construction:

// http/order.routes.ts — 200 OrderResponse | 404, no discriminator
const result = await repo.byRef(params.ref);
if (result == null) throw new AggregateNotFoundError("not_found");
return c.json(repo.toWire(result) as z.infer<typeof OrderResponse>, 200);
// Application/Orders/Queries/ByRefQuery.cs
public sealed record ByRefQuery(string Ref) : IQuery<OrderResponse?>;
// Api/OrdersController.cs
var result = await _mediator.Send(new ByRefQuery(@ref));
if (result is null) throw new AggregateNotFoundException("not_found");
return Ok(result);
// features/orders/OrdersController.java
var r = service.byRef(ref);
if (r == null) { throw new AggregateNotFoundException("not_found"); }
return ResponseEntity.ok(r);
# app/http/order_routes.py
if (found := await repo.by_ref(ref)) is None:
    raise AggregateNotFoundError("not_found")
return repo.to_wire(found)
# lib/api_elixir_web/controllers/order_controller.ex
case Orders.by_ref_order(params["ref"]) do
  {:ok, nil} -> ProblemDetails.problem_response(conn, 404, "Not Found", "not_found")
  {:ok, record} -> json(conn, serialize(record))
end

The tagged-wire machinery — z.discriminatedUnion("type", …) / [JsonPolymorphic] / @JsonTypeInfo — backs the anonymous A or B union and the named payload F = A | B form in their payload-field and operation-return positions. The full union surface (variant kinds, precedence, position rules, the loom.union-* validators, the Agg or <error> find shape) lives in Payloads & unions and ../payloads.md.