Port Types
Typed ports, connection validation, and scope isolation.
Every edge on the canvas is validated against a type system before it is accepted. Port types let the editor enforce correctness at authoring time - if two nodes cannot be wired together, the connection is rejected immediately with an explanation, rather than failing silently at runtime.
Port definitions
Each node type declares a list of PortDefinition entries - one per input or output handle:
interface PortDefinition {
id: string // handle ID (e.g., 'output', 'true', 'false', 'timeout')
direction: 'input' | 'output'
type: PortType
label?: string // display label (defaults to id)
required?: boolean // whether this input must be wired for the node to be valid
}When a node has no port definitions, all its handles are treated as any - accepting any connection. This maintains backward compatibility with older node types that predate the type system.
Port type kinds
The type algebra supports eight kinds:
| Kind | Description | Example |
|---|---|---|
any | Untyped - accepts any connection | Legacy / passthrough nodes |
string | Plain text value | linear.text.strip_mentions output |
number | Numeric value | agent.dispatch_count.increment output |
boolean | True/false value | Condition node output |
object | JSON Schema constrained object | linear.issue.read output |
array | Typed array (element type declared) | parallel.fanout branch list |
union | One of several types (A or B or C) | Mixed-type outputs |
null | Explicit null (for null propagation) | Optional outputs with no value |
struct | Nominal - references a workspace struct type | Custom-typed boundary ports |
Nullable ports
Any port type can be marked nullable: true. A nullable port can carry either its declared type or null. Connecting a nullable output to a non-nullable input is allowed but may trigger null propagation at runtime if the value is actually null.
Struct ports
struct ports reference a workspace-scoped struct type by id. Two struct ports are compatible only when they reference the same struct type (by ID). If both ports declare a version, the versions must match; one versioned and one unversioned port is accepted.
Struct-typed ports cannot be used on group bundle interface ports that are published to the marketplace. The platform rejects the publish with an error because StructRef IDs are workspace-scoped and cannot be resolved in the consumer's workspace.
Convenience constructors (DEV)
Node definitions use the PortTypes helper for concise declarations:
import { PortTypes } from '@/lib/workflow/port-types'
// Primitives
PortTypes.Any // { kind: 'any' }
PortTypes.String // { kind: 'string' }
PortTypes.Number // { kind: 'number' }
PortTypes.Boolean // { kind: 'boolean' }
PortTypes.Null // { kind: 'null' }
// Compound types
PortTypes.Object(schema?) // { kind: 'object', schema }
PortTypes.Array(PortTypes.String) // { kind: 'array', elementType: string }
PortTypes.Union(PortTypes.String, PortTypes.Number) // union
PortTypes.Nullable(PortTypes.String) // { kind: 'string', nullable: true }
PortTypes.Struct({ id: 'my-struct-id', name: 'MyType' })Connection validation rules
When you drag an edge from one node to another, the platform checks these rules in order. The first failing check blocks the connection:
Structural rules
- No self-loops - a node cannot connect to itself.
- Trigger nodes cannot have incoming edges - triggers are always start nodes.
- Annotation nodes cannot be connected - annotations are canvas decorations only.
- Single-input invariant - non-fanin nodes accept at most one incoming edge per input handle. Attempting to wire a second edge to the same input is rejected.
- Condition node outputs - in binary mode, a condition node may have exactly two outgoing edges (
trueandfalse). In switch mode, one edge per declared case plus an optionaldefault.
Type compatibility rules
- Port type matching - the output port type must be compatible with the input port type. The compatibility rules are:
anyis compatible with everything (bidirectional).- Primitive types (
string,number,boolean) must match exactly, unless one end isany. objectports are compatible when the output schema is a structural subset of the input schema (output provides at least what input requires).unionoutput is compatible with an input if any union member is compatible with the input type.structports must reference the same struct type ID (and matching version if both are versioned).- A nullable output is accepted by a non-nullable input, but the runtime may propagate null downstream if the value is null at execution time.
If a connection is incompatible, the drag fails with a tooltip showing the specific type mismatch (e.g., "Cannot connect String → Number").
Scope isolation
Within a workflow that has group containers, edges are additionally constrained by scope rules:
- An edge may only be created within a single scope. You cannot draw a direct edge from a node inside a group to a node outside (or vice versa) - you must use the group's boundary interface ports.
- Boundary ports act as typed gateways: a node inside the group connects to the group's input boundary port, and the boundary output port connects to nodes in the parent scope.
- Attempting to wire across a scope boundary directly shows the error:
"Cannot connect across scope boundary - use interface ports".
This rule is enforced both at drag time (in the canvas) and at save time (in the canvas-to-definition validator). Both layers must agree for the workflow to be valid.
Validation at save time
When you publish or export a workflow, the platform runs a full connection validation pass (validateWorkflowFull) over all edges. Errors block the publish and are surfaced in the broken-state banner. Individual edge errors show inline on the canvas with red highlights.
You can also trigger manual validation from the workflow controls menu (Validate canvas) without publishing.
Human-readable port labels
The portTypeLabel() utility converts a PortType to a display string used in error messages and the node preview popover:
| Type | Label |
|---|---|
{ kind: 'any' } | Any |
{ kind: 'string' } | String |
{ kind: 'object', schema: { title: 'Issue' } } | Issue |
{ kind: 'array', elementType: string } | String[] |
{ kind: 'union', members: [string, number] } | String | Number |
{ kind: 'struct', structRef: { name: 'Ticket' } } | Ticket |
Example: typed trigger output
Here is how a linear.issue.assigned trigger node might declare its output port:
// Simplified from src/lib/nodes/trigger/linear.issue.assigned/backend.ts
ports: [
{
id: 'output',
direction: 'output',
type: PortTypes.Object({
$id: 'rensei.trigger.linear.IssueAssignedPayload',
title: 'IssueAssignedPayload',
properties: {
issue: { type: 'object' },
actor: { type: 'object' },
},
}),
},
]A downstream action node that declares its input as PortTypes.Any will accept this connection. A node expecting PortTypes.String will be rejected at drag time.
Related pages
- Canvas - drawing connections and the validation UX
- Node Config Panels - port type editor for group interfaces
- Groups - scope model and boundary ports
- Struct Types - workspace-scoped named object shapes
- Expressions -
{{ }}variable syntax that flows through typed ports - DAG Executor - null propagation at runtime