Port Data Validation
Runtime JSON-Schema checks on the message data flowing into and out of a node's ports — opt-in per port. The runtime counterpart to the compile-time Wire Type-Checking.
nrg checks a wire in two complementary ways:
| when | what it checks | how | |
|---|---|---|---|
| Wire Type-Checking | deploy / author time | the two ports' types line up | TypeScript types (the wire check) |
| Port Data Validation | runtime, every message | the actual message is well-formed | JSON Schema, per-port toggle |
This page is the second one.
Why — what type-checking can't do
Wire type-checking is powerful, but it is compile-time and structural: the Input/Output (and Port<T>) generics are erased at build. They drive the editor's port topology, type your handler while you author, and let the wire check red a mis-wire before you deploy — but they do not exist at runtime. So types alone can't:
- Guarantee data that comes from outside the flow. An HTTP request body, a file's contents, an MQTT message, a DB row — TypeScript types it as whatever you declared (or
any/unknown), but the value that actually arrives is whatever the outside world sent. The type is a promise; validation is the enforcement. - Express value constraints. "a non-empty string", "an email", "0 ≤ n ≤ 100", "one of these five statuses", "an array with at least one item" — most of these a type can't state, and none it can enforce once the code is running.
- Defend a trust boundary. The wire check trusts the declared types; a malformed or hostile message at runtime sails straight through them. Validation rejects it — to the error port — instead of letting it crash deep in your handler.
Rule of thumb:
- Type-checking answers "are these two ports compatible?" — once, at author time.
- Data validation answers "is this actual message well-formed?" — at runtime, on every message.
Use them together: types keep the wiring correct; validation guards the boundaries where real-world data enters, and the contracts downstream consumers rely on.
When to turn it on
- At an ingest / trust boundary — the node that first pulls data in from outside (an HTTP-in, a file read, a broker subscription). Fail fast on bad data instead of somewhere deep downstream.
- To enforce value constraints the type can't — ranges, formats, enums, patterns, runtime-required fields.
- To publish a checkable contract on an output that a flow author can tighten per instance.
Skip it when the wire type is enough and you're happy to handle whatever arrives. It is off by default: nothing validates unless a schema resolves for the port and that port's Validate Data toggle is on.
It checks — it does not coerce
Port validation runs a pure predicate: no coercion, no defaults, and it never rewrites the message. If it fails the node throws (routed to the error port when enabled); if it passes, the message flows through byte-for-byte.
This is the opposite of how config, credentials, and settings are handled — those are coerced and defaulted (they come from the editor form as strings). That difference, and the config side, is covered in Schema Validation. This page is only about message data on ports.
Input Data Validation
Input validation checks incoming messages before they reach your input() handler. It doesn't create the input port (the Input generic does) and it isn't a static on the class — it's a config-schema framework control. The input Data Schema editor and Validate Data toggle render on every IONode already; adding a SchemaType.InputSchema() field to your config schema just seeds a default JSON Schema in that editor, which the flow author can override.
import { defineSchema, SchemaType } from "@bonsae/nrg/schema";
export const ConfigsSchema = defineSchema(
{
name: SchemaType.String({ default: "" }),
// seed a default input-validation schema (Monaco-editable by the flow author)
inputSchema: SchemaType.InputSchema({
default: JSON.stringify({
type: "object",
properties: { payload: { type: "string" } },
required: ["payload"],
}),
}),
},
{ $id: "my-node:configs" },
);Validation runs when the flow author turns on the port's Validate Data toggle (which sets config.validateInput). Invalid messages throw — routed to the error port when it's enabled.
What input validation checks
Input validation runs against the whole incoming message, not only the fields your Input type declares — the schema alone decides what's allowed:
- It's independent of the port type. The
inputSchemais authored (your default, or the flow author's editor override), so it can require or shape fields theInput<Port<T>>type never mentions, and vice versa. The type drives the wire check and types your handler; the schema is the runtime gate. - Leave
additionalPropertiesopen to check only the fields you read — see Validate only what you read below.
Output Data Validation
Output validation checks the outgoing record — this send's additions merged onto the accumulating message ({ ...incoming, ...additions }), i.e. exactly what leaves the port — before it's emitted. Seed per-port defaults with a SchemaType.OutputSchemas() field, keyed by output port index:
import { defineSchema, SchemaType } from "@bonsae/nrg/schema";
export const ConfigsSchema = defineSchema(
{
name: SchemaType.String({ default: "" }),
// seed a default validation schema for output port 0
outputSchemas: SchemaType.OutputSchemas({
default: {
0: JSON.stringify({
type: "object",
properties: {
result: { type: "string" },
timestamp: { type: "number" },
},
}),
},
}),
},
{ $id: "my-node:configs" },
);A port validates when the flow author turns on its Validate Data toggle (which sets config.validateOutputs[port]) and a schema exists for that port. Only ports you seed a default for are overridable in the editor.
What output validation checks
Symmetric with input, output validation checks the whole outgoing record — the merged frame, not only the fields this send named:
- It gates any field on the wire. A port schema can require or shape a field an upstream node contributed, not just the ones this node adds. (A bare
send(port)that forwards the record with no additions contributes nothing, so it's skipped.) - Leave
additionalPropertiesopen — same rule as input; see below.
Port count and names come from the Output generic, not from these schemas. See Named Output Ports.
Validate only what you read (vs. everything)
The message is the flow's shared, accumulating record — it carries fields many nodes added upstream, plus the framework's _meta/_msgid carriers. So a data schema should normally name just the fields this node depends on and let the rest pass:
// this node reads msg.order.total — it doesn't care what else rode in
{
"type": "object",
"properties": {
"order": {
"type": "object",
"properties": { "total": { "type": "number", "minimum": 0 } },
"required": ["total"]
}
},
"required": ["order"]
}Because JSON Schema allows extra properties by default (additionalProperties is true), every accumulated field passes untouched — only order.total is enforced. This is the common case for both input and output, and you don't have to list what you don't read.
If instead you want to reject anything not named — a locked-down contract — set additionalProperties: false. Do this only when you truly own the whole message shape: on a flowing record it rejects every legitimately-accumulated field (and the framework carriers), so it's rare for a port on a shared record.
{
"type": "object",
"properties": { "status": { "enum": ["ok", "error"] } },
"required": ["status"],
"additionalProperties": false
}Either way it's the flow author's call — the schema they see in the editor decides.
Configuring validation in the editor
Data validation is a config-schema framework control — never a static on the class. The controls render on every IONode automatically; declaring the config field just seeds the node author's default. Two pieces make it work:
- The Validate Data toggle. Every IONode's Ports Settings table shows a Validate Data toggle per port (input and output). Toggling it writes
config.validateInput/config.validateOutputs[port]. - The Data Schema editor. An editable Data Schema button (a Monaco JSON editor) appears per port. Declaring
SchemaType.InputSchema()(input) /SchemaType.OutputSchemas()(outputs) seeds that editor with yourdefault.
const ConfigsSchema = defineSchema(
{
name: SchemaType.String({ default: "" }),
// expose an editable input-validation schema (Monaco), seeded with a default
inputSchema: SchemaType.InputSchema({ default: '{ "type": "object" }' }),
// expose per-port output-validation schemas; only ports given a default here
// are overridable — port 0 is editable, port 1 stays disabled
outputSchemas: SchemaType.OutputSchemas({
default: { 0: '{ "type": "object" }' },
}),
},
{ $id: "my-node:configs" },
);The schema a flow author types is stored as a JSON-Schema string in config.inputSchema / config.outputSchemas[port]; nrg uses it when present and valid. An unparseable or non-compiling schema is ignored (warned once). Validation still runs only when the port's Validate Data toggle is on.
