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: "list"; of: TypeRef }
| { kind: "any" };
list is the only recursive case (of: TypeRef); every other kind is a leaf. Dimension is the closed string union from libs/shared/src/domain/dimensions.ts.
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, ref |
DIMENSIONS (domain/dimensions.ts), OBJECT_TYPES (domain/object-types.ts), 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, unit must belong to that dimension (dimensionOfUnit) |
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 |
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 |
typeof value === "string" |
date without time |
DateTime |
string |
typeof value === "string" |
ISO 8601 instant |
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.
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 |
list |
List<typeLabel(of)>, recursively |
any |
Any |
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. |
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 : …).
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 |
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.enum(["Patient", "Encounter", "Order", "Document"]) }),
z.object({ kind: z.literal("object"), name: z.string() }),
z.object({ kind: z.literal("list"), of: TypeRefSchema }),
z.object({ kind: z.literal("any") }),
]),
);
One asymmetry: the manifest schema types quantity.dimension as a bare z.string().optional(), while the internal TypeRef types it as the closed Dimension union. A manifest is free to name any dimension string; it only becomes meaningful once that name resolves in the DIMENSIONS registry (see below).
TypeRef values appear in a manifest wherever a type field is expected: contributes.types[].fields[].type, contributes.nodes[].inputs[].type / outputs[].type, and contributes.nodes[].config[].type. The host applies a fixed contribution order — value-sets → dimensions → types → conversions → nodes → adapters → agents → eventSources → eventBindings — so any coded/quantity/object reference inside a types or nodes contribution resolves against a registry already populated earlier in the same load pass. See Plugin manifest.
Backing registries
coded, quantity, object, and ref resolve names against open, in-memory registries. All of them are populated by plugins at load time; none is exposed for direct mutation outside their 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 |
Yes — pre-seeded with 10 generic physical dimensions (dimensionless, fraction, mass, volume, time, amount-of-substance, mass-concentration, molar-concentration, pressure, temperature) |
registerDimension(name, def) |
Silently overwrites — no collision guard |
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 |
The Dimension TS union additionally lists filtration-rate, which is not among the core-seeded entries: it is declared in the shared vocabulary but only becomes usable once something (typically a clinical plugin) calls registerDimension("filtration-rate", …).
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.