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(thetimerSourceliteral) · Validators: type-system checks insrc/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(Filestorage)
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, sotimeis refused with that reason (Unknown type 'time'. Loom has no time-of-day type …) rather than offered as a near miss. Usedatetimefor an instant, ordatefor 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:
- at compile time — an integer literal past the ceiling is
loom.integer-literal-imprecise. TheINTterminal returns a JSnumber, so9007199254740993was already…92before any emitter ran, and every target emitted the rounded value; - at the wire boundary — the node request schema bounds an inbound
long(enforced, deliberately not published: .NET/java/elixir carry int64 and publish the plain integer, and a node-only bound in the OpenAPI would make one.dddpublish two contracts).
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 thanfind allis an error (loom.list-find-removed): a list read is acriterionpassed toRepo.run(…)or a namedretrieval— 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 namedEnvelope<Order>in the repository port, the Spring Data interface and the impl and declared the type nowhere (no class, no import), then calledOrderResponse.from(…)on it; a@Query(…) Envelope<Order> audit();is not a Spring-Data-mappable return either. dotnet did not compile — it declaredrecord Envelope<T>(string Id, DateTime Ts, T Body)and then returned a bareOrderfrom aTask<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 sourcets. 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, andenvelopeis unwrapped to its argument at each backend's find-return seam.envelopeis 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
.dddin the repository instantiated the carrier, so every compile gate was blind to it by construction.test/fixtures/corpus/envelope.dddis 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.