Documentation / Développement / Référence / TypeRef

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. 2026 and 2026-06 are valid FHIR dateTime values, 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") is 2026-01-01T00:00:00Z.
  • A time requires seconds and an offset. Without an offset, Date.parse reads 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.parse alone is not the rule. It accepts 1, May 11, 2026 and 01/05/2026 (read as January 5), and rolls 2026-02-30 over 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:

  1. any wildcard. If either from.kind or to.kind is any, the connection is allowed.
  2. coded → primitive widening. A coded output may feed a primitive input only if the target is Code or String.
  3. Otherwise from.kind must equal to.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.

  • Plugin manifest — the full plugin.yaml schema, including where TypeRef appears.
  • Generate types and Codegen internals — how TypeRef is 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_KINDS in depth.
  • Static validation engine — how isAssignable is used to check a whole workflow.
  • The type system — the conceptual model behind the layered vocabulary.
  • Port contracts — how TypeRef shows up on PortDef/ConfigFieldDef.
  • MapExpr — the separate grammar for projecting raw records into typed event fields.
97 documents14 sectionssource : /docs · généré au build