TypeRef
TypeRef is the discriminated union that describes the type of every node port and every object field in Meridian. It is defined in libs/shared/src/domain/types.ts and re-exported to plugins from @meridian/shared/domain/types. A TypeRef value is pure data (JSON-serializable) — the same descriptor is written in a plugin's plugin.yaml, carried at runtime on PortDef/ConfigFieldDef/FieldDescriptor, projected to TypeScript by codegen, and consumed by the static-validation and runtime-validation engines.
The union
export type TypeRef =
| { kind: "primitive"; name: PrimitiveName }
| { kind: "coded"; valueSet: string }
| { kind: "quantity"; dimension?: Dimension }
| { kind: "ref"; entity: string }
| { kind: "object"; name: string }
| { kind: "record"; name: string; fields: Record<string, { type: TypeRef; required?: boolean }> }
| { kind: "list"; of: TypeRef }
| { kind: "any" };
list and record are recursive cases. Dimension is a string, the name of a dimension of the DIMENSIONS registry (libs/shared/src/domain/dimensions.ts), contributed by a plugin.
The vocabulary is layered, per the module's header comment:
| Layer | Kind(s) | Backed by |
|---|---|---|
| 0 — primitives | primitive |
the closed PrimitiveName union (no registry) |
| 1 — coded | coded |
the VALUE_SETS registry (domain/value-sets.ts) |
| 2 — measures & structures | quantity, object, record, ref |
DIMENSIONS (domain/dimensions.ts), OBJECT_TYPES (domain/object-types.ts), workflow-local structural fields, and CONTEXT_KINDS (domain/context-kinds.ts — a ref entity is the entity of a contributed context kind) |
any and list sit outside the layering: any is a wildcard (see Structural assignability); list wraps any other TypeRef, including another list.
Kinds
kind |
Fields (besides kind) |
Meaning | Generated TypeScript | Runtime value checked by validateValue |
|---|---|---|---|---|
primitive |
name: PrimitiveName |
A language-level scalar. | per PrimitiveName, see table below |
per PrimitiveName, see below |
coded |
valueSet: string |
A code bound to a value-set (VALUE_SETS registry id). |
literal union of the value-set's codes, or string if the value-set is external, has no enumerated codes, or isn't in the plugin's dependsOn closure |
typeof value === "string"; if the value-set is resolvable internally (not external), the code must also be a member of it |
quantity |
dimension?: Dimension |
A dimension-aware measure (UCUM-style unit). Omitting dimension accepts any measure. |
{ value: number; unit: string; system?: string } |
an object with value: number (non-NaN) and unit: string; if dimension is set, the dimension must be registered and unit must be one of its units (unitInDimension) |
object |
name: string |
A datatype or resource registered in OBJECT_TYPES (by name). |
the generated interface for that name (imported from the owning plugin's package if not self-owned) |
an object satisfying every field of the registered TypeDescriptor, recursively — see Backing registries |
record |
name: string, fields |
A structural object carried by one workflow contract, such as mapped message rows. | an inline object type | an object satisfying every declared required field and each field's recursive type |
ref |
entity: string |
A reference to the entity of a contributed context kind (e.g. Patient for the patient kind), by id only — open, resolved against the CONTEXT_KINDS registry. |
{ id: string } |
an object with id: string |
list |
of: TypeRef |
A homogeneous list of another TypeRef. |
T[], where T is the mapping of of |
an array whose every element satisfies of (index reported on failure) |
any |
— | A flow port whose type is inferred from wiring (used by engine primitives such as flow.switch/flow.map/flow.guard, and transform.*). |
unknown |
always valid |
Primitive names
PrimitiveName is a closed, FHIR-aligned enum — the only kind with no backing registry:
PrimitiveName |
Generated TypeScript (PRIMITIVE_TS) |
Runtime check (validateValue) |
Notes |
|---|---|---|---|
String |
string |
typeof value === "string" |
free text |
Number |
number |
typeof value === "number" && !Number.isNaN(value) |
retained as a back-compat alias of Decimal (see code comment in domain/types.ts) |
Decimal |
number |
typeof value === "number" && !Number.isNaN(value) |
real value |
Integer |
number |
typeof value === "number" && Number.isInteger(value) |
|
Boolean |
boolean |
typeof value === "boolean" |
|
Date |
string |
isDate(value): YYYY-MM-DD, day present in the calendar |
date without time, see Date and DateTime formats |
DateTime |
string |
isDateTime(value): FHIR dateTime grammar |
ISO 8601 date-time with offset, or a reduced precision, see Date and DateTime formats |
Time |
string |
typeof value === "string" |
|
Duration |
string |
typeof value === "string" |
ISO 8601 duration or seconds |
Code |
string |
typeof value === "string" |
a raw token, before terminology binding |
Uri |
string |
typeof value === "string" |
Codegen looks the name up in a fixed PRIMITIVE_TS map (libs/plugin-cli/src/codegen.ts); every PrimitiveName has an entry, so the map's ?? "unknown" fallback is never exercised in practice.
Date and DateTime formats
Date and DateTime are the only string primitives whose content is checked. Time, Duration, Code and Uri accept any string. The two predicates are exported from @meridian/shared/engine/type-system so plugin code can check a value before emitting it:
| Predicate | Accepted | Examples accepted | Examples refused |
|---|---|---|---|
isDate(value) |
YYYY-MM-DD, where the day exists in that month (leap years included) |
2026-06-11, 2024-02-29 |
"", 2026-06, 2026-02-30, 2026-06-11T08:30:00Z, 11/06/2026, 20260611 |
isDateTime(value) |
YYYY, YYYY-MM, YYYY-MM-DD, or YYYY-MM-DDThh:mm:ss[.fraction] followed by Z or ±hh:mm (offset from -14:00 to +14:00, fraction of 1 to 9 digits); every accepted value is also readable by Date.parse |
2026-06-11T08:30:00Z, 2026-06-11T08:30:00.123+02:00, 2026-06-11, 2026-06, 2026 |
"", 2026-06-11T08:30:00 (no offset), 2026-06-11T08:30Z (no seconds), 2026-06-11T08:30:00+0200, 2026-06-11 08:30:00, 20260611083000, 2026-02-30, May 11, 2026 |
The DateTime grammar is the FHIR dateTime grammar, because FHIR adapters read and write these values as dateTime elements (effectiveDateTime, onsetDateTime, authoredOn, Communication.sent). Consequences:
- Reduced precisions are accepted.
2026and2026-06are valid FHIRdateTimevalues, and clinical data carries them (a condition onset known only by year). A consumer that needs an instant reads the start of the period:Date.parse("2026")is2026-01-01T00:00:00Z. - A time requires seconds and an offset. Without an offset,
Date.parsereads the time in the host's local time zone, so the same string would mean different instants on different servers; FHIR also requires the offset. Date.parsealone is not the rule. It accepts1,May 11, 2026and01/05/2026(read as January 5), and rolls2026-02-30over to March 2.
Date is stricter than FHIR date, which also allows YYYY and YYYY-MM: a Date value is always a full calendar day. Every valid Date is therefore also a valid DateTime.
A producer with no date to report emits nothing on the port (the key is absent from the returned outputs): absence propagates downstream, whereas "" fails the check. A source date in another format (HL7 v2 TS/DTM, dd/mm/yyyy) is converted before it is emitted.
Construction helpers — the t namespace
domain/types.ts exports a t object of ready-made TypeRef values and factories, used throughout the core catalogue (engine/catalog-metadata.ts) and available to plugin code:
| Member | Value / signature |
|---|---|
t.string, t.number, t.decimal, t.integer, t.boolean, t.date, t.dateTime, t.time, t.duration, t.code, t.uri |
{ kind: "primitive", name: <PrimitiveName> } for the matching name |
t.ref(entity: string): TypeRef |
{ kind: "ref", entity } |
t.coded(valueSet: string): TypeRef |
{ kind: "coded", valueSet } |
t.quantity(dimension?: Dimension): TypeRef |
{ kind: "quantity", dimension } |
t.obj(name: string): TypeRef |
{ kind: "object", name } |
t.list(of: TypeRef): TypeRef |
{ kind: "list", of } |
t.any |
{ kind: "any" } |
Human-readable labels — typeLabel
typeLabel(type: TypeRef): string (domain/types.ts) renders a TypeRef for logs, error messages, and the catalogue UX:
kind |
Output |
|---|---|
primitive |
name (e.g. Decimal) |
coded |
Code<valueSet> |
quantity |
Quantity<dimension> if dimension is set, else Quantity |
ref |
entity `Ref` (e.g. PatientRef) |
object |
name |
record |
name |
list |
List<typeLabel(of)>, recursively |
any |
Any |
Homonyms — distinctTypeLabel
Two distinct types can share a typeLabel: the core quantity kind and an
object type registered as Quantity (by @posos/common), a primitive and an
object type of the same name, a workflow record and a registered type. The
labels written for a reader then carry the type's origin, read from the
registries (libs/shared/src/engine/type-system.ts):
| Function | Output |
|---|---|
isHomonymType(type) |
true when another known type renders the same label: for an object, its name is also a core label (a primitive name, Any, Quantity, Quantity<…>, Code<…>, List<…>, or <Entity>Ref of a registered context kind); for a record, its name is also a registered type or a core label; for a core kind, its label is also a registered type name. |
typeOrigin(type, words?) |
The contributing plugin (source of the descriptor) for an object, type du registre when it has none, structure locale for a record, type du cœur otherwise. words replaces these three words (the console passes its translations). |
distinctTypeLabel(type, words?) |
typeLabel, followed by (typeOrigin) for a homonym: Quantity (type du cœur), Quantity (@posos/common), List<Quantity (type du cœur)>. Unchanged otherwise. |
distinctTypeLabels(a, b, words?) |
The two labels of a mismatch. When they are still equal for two different types (two record contracts of the same name), each is written with its fields: Ligne { code: String }. |
The connection check of validateSpec, the memory read and write messages,
the editor's refused-connection message and the assistant's catalog digest use
these labels:
Connexion trigger.result.value → b.value : type incompatible (Quantity (type du cœur) → Quantity (@posos/common)).
Structural assignability — isAssignable
isAssignable(from: TypeRef, to: TypeRef): boolean (libs/shared/src/engine/type-system.ts) decides whether a connection's source output type (from) may feed a target input type (to). It is evaluated in this order:
anywildcard. If eitherfrom.kindorto.kindisany, the connection is allowed.coded→primitivewidening. A coded output may feed a primitive input only if the target isCodeorString.- Otherwise
from.kindmust equalto.kind; the rule then depends on the kind:
kind |
Rule |
|---|---|
primitive |
Same name → allowed. Otherwise: the numeric family (Number, Decimal, Integer) is mutually assignable except into Integer — only Integer → Integer is allowed for that target, so Number/Decimal cannot feed an Integer port. The free-text family (String, Code, Uri) is fully mutually assignable. Any string-represented primitive (String, Code, Uri, Date, DateTime, Time, Duration) may additionally widen into a String target. |
coded |
Allowed only when from.valueSet === to.valueSet — exact match. Hierarchical subsumption (subsumes() in domain/value-sets.ts) is not consulted here; it stays intra-value-set and serves other consumers, not connection compatibility. |
quantity |
If to.dimension is undefined, any quantity is accepted. Otherwise from.dimension must equal to.dimension exactly — no implicit unit conversion at this level (that is the contributes.conversions mechanism, powering the transform.convert node). |
ref |
Allowed only when from.entity === to.entity. |
list |
Allowed when isAssignable(from.of, to.of) — recursive, element-wise. |
object |
Allowed when from.name === to.name, or by structural subtyping: every required field of to (per OBJECT_TYPES) must exist in from's fields with a recursively assignable type. A to with no known fields (unresolved type) is never satisfied this way. |
record |
Uses the same structural rule as object. object and record may connect across kinds when every required target field is present with an assignable type. |
Runtime validation — validateValue
validateValue(type: TypeRef, value: unknown): { ok: boolean; error?: string } (engine/type-system.ts) checks a concrete value against a TypeRef at port boundaries (trigger payload → outputs, node outputs → downstream inputs). The per-kind checks are listed in Kinds and Primitive names above; list and object recurse and report the first failing element/field (élément [i] : …, TypeName.field : …). A Date/DateTime string outside its format gets an error naming the expected format and the received value, truncated to 40 characters: attendu DateTime ISO 8601 (ex. 2026-06-11T08:30:00Z, 2026-06-11T08:30:00+02:00 ou 2026-06-11), reçu « 20260611083000 ».
The same function checks every boundary, so the date formats apply to all of them:
| Boundary | Caller | Outcome on failure |
|---|---|---|
| Trigger outputs and every node output | checkOutputs (libs/engine-core/src/engine/interpreter.ts) |
WorkflowDataError, the run fails on that node |
Inline inputs: constants of a workflow |
validateSpec (libs/engine-core/src/engine/validate.ts) |
validation error: publication and simulation are refused |
Static config values of Date/DateTime fields (value.datetime, flow.wait until and limit.at), unless exposed or empty |
validateSpec |
validation error: publication and simulation are refused |
Typed body fields of a trigger.webhook |
API host (apps/api/src/server/routes/webhooks.ts) |
the request is refused before a run starts |
| Inputs of an exposed agent | API host (routes/agents.ts, openai-compat.ts) |
the request is refused |
| Outputs of an LLM agent | runner (external-plugins/llm/adapters/runner.ts) |
the error is returned to the model, which retries within the agent's iteration limit; the run fails once the limit is reached |
Example values — exampleValue
exampleValue(type: TypeRef): unknown (engine/type-system.ts) produces a deterministic value conforming to a TypeRef, used by the mock agent runner and default-value generation:
kind |
Example produced |
|---|---|
primitive |
0 (numeric names), true (Boolean), a fixed ISO string for Date/DateTime/Time/Duration (2026-06-08, 2026-06-08T00:00:00.000Z, 00:00:00, PT0S), "" otherwise |
coded |
the first concept's code in the value-set, or "" if it has none |
quantity |
{ value: 0, unit }, where unit is the dimension's canonical unit (or "1" with no dimension) |
ref |
{ id: "example" } |
list |
[] |
object, record |
an object populated with exampleValue for every required field only |
any |
null |
YAML encoding (plugin manifest)
plugin.yaml encodes TypeRef values with the exact same shape, validated by the recursive TypeRefSchema (Zod) in libs/shared/src/plugin/manifest.ts:
const TypeRefSchema: z.ZodType = z.lazy(() =>
z.union([
z.object({ kind: z.literal("primitive"), name: PrimitiveName }),
z.object({ kind: z.literal("coded"), valueSet: z.string() }),
z.object({ kind: z.literal("quantity"), dimension: z.string().optional() }),
z.object({ kind: z.literal("ref"), entity: z.string().min(1) }),
z.object({ kind: z.literal("object"), name: z.string() }),
z.object({ kind: z.literal("record"), name: z.string().min(1), fields: z.record(z.object({ type: TypeRefSchema, required: z.boolean().optional() })) }),
z.object({ kind: z.literal("list"), of: TypeRefSchema }),
z.object({ kind: z.literal("any") }),
]),
);
A manifest names a dimension by a string (quantity.dimension, z.string().optional()), like the internal TypeRef. The name only becomes meaningful once it resolves in the DIMENSIONS registry (see below). At load, a declared event field or a node port on a dimension outside the plugin's dependsOn closure is dropped. At runtime, a value on a dimension that is not registered is refused with « dimension « X » inconnue. Charger le plugin qui la contribue. »
TypeRef values appear in a manifest wherever a type field is expected: contributes.types[].fields[].type, contributes.events[].fields[].type (a type name, resolved like a type hint, or a full TypeRef), contributes.nodes[].inputs[].type / outputs[].type, and contributes.nodes[].config[].type. The host applies a fixed contribution order — value-sets → context kinds → dimensions → types → conversions → events → nodes → adapters → agents → event sources → message bindings → event bindings → port sandboxes — so any coded/quantity/object reference inside a types, events or nodes contribution resolves against a registry already populated earlier in the same load pass. An event's field types, like its context kinds, must resolve in the plugin's dependsOn closure, and so must the dimensions of an event field or a node port. See Plugin manifest.
Backing registries
coded, quantity, object, and ref resolve names against open, in-memory registries. A record carries its field descriptors inline and needs no registry. The registries are populated by plugins at load time; none is exposed for direct mutation outside its registration function.
| Registry | File | Core-seeded? | Registration function | Collision behavior |
|---|---|---|---|---|
OBJECT_TYPES (object) |
domain/object-types.ts |
No — starts empty; the core contributes no domain type | registerType(name, descriptor) |
Throws if name is already registered with a different descriptor |
VALUE_SETS (coded) |
domain/value-sets.ts |
No — starts empty | registerValueSet(def) |
Throws if def.id is already registered with a different definition |
DIMENSIONS (quantity) |
domain/dimensions.ts |
No — starts empty; the core declares no dimension. @posos/common contributes the common physical dimensions (dimensionless, fraction, mass, volume, time, amount-of-substance, mass-concentration, molar-concentration, pressure, temperature) |
registerDimension(name, def) |
Throws on any other definition of a registered name; the same definition from the same plugin is left in place |
CONTEXT_KINDS (ref) |
domain/context-kinds.ts |
No — starts empty; the core contributes no context kind | registerContextKind(descriptor) |
Throws if name is already registered with a different descriptor |
A plugin whose ports use a dimension of another plugin declares that plugin in its dependsOn, so it loads after it: a port typed { kind: quantity, dimension: mass } needs @posos/common.
getType/hasValueSet/dimensionOfUnit (and friends) are the read-side accessors isAssignable, validateValue, and exampleValue use to resolve a TypeRef against these registries. See Type-system registries and Add vocabulary and types.
Ambient context (same module)
domain/types.ts also defines ContextKey (an open string — a context-kind name) and ContextEnvelope (Record<string, { id: string } | undefined>, one key per established kind) — the ambient context a workflow run carries, distinct from TypeRef's ref kind (which types a single port value, not the run's context). Node metadata references ContextKey in its context/establishes fields. See Workflow DSL and Core primitives.
Related
- Plugin manifest — the full
plugin.yamlschema, including whereTypeRefappears. - Generate types and Codegen internals — how
TypeRefis projected to TypeScript. - Add vocabulary and types — contributing value-sets, dimensions, and types from a plugin.
- Type-system registries —
OBJECT_TYPES,VALUE_SETS,DIMENSIONS,CONTEXT_KINDSin depth. - Static validation engine — how
isAssignableis used to check a whole workflow. - The type system — the conceptual model behind the layered vocabulary.
- Port contracts — how
TypeRefshows up onPortDef/ConfigFieldDef. - MapExpr — the separate grammar for projecting raw records into typed event fields.