| name | typescript |
| description | TypeScript 5.4+ patterns for the Relay codebase — strict mode discipline, ESM with NodeNext resolution, discriminated unions (used heavily in Runner types and InvocationEvent), Zod schema inference, type narrowing, branded types, the ban on `any` and `as` casts, and the import-extension rules ESM enforces. Trigger this skill when writing or refactoring any `.ts` file, when designing types for a new module, when the type system is fighting you, or when an agent is tempted to reach for `any` or `// @ts-ignore`. |
TypeScript Patterns for Relay
The Relay codebase is strict TypeScript 5.4+, ESM-only, Node ≥22.0. Strict means strict — noImplicitAny, strictNullChecks, noUncheckedIndexedAccess, verbatimModuleSyntax all on. The type system is load-bearing; step authors get autocomplete and refactor safety from it, not from documentation.
Hard rules
- No
any. If the type is genuinely unknown, use unknown and narrow before use. any defeats the system.
- No
as casts unless you're narrowing through a discriminator the compiler can't follow. Use type guards.
- No
// @ts-ignore and no // @ts-expect-error in production code. Errors get fixed.
- Imports include the
.js extension (NodeNext resolution requires it, even for .ts source files).
import type for type-only imports. verbatimModuleSyntax enforces this.
- Re-export from a single
index.ts per module. The public surface is what index.ts says it is.
ESM specifics (NodeNext)
import { Orchestrator } from './orchestrator/orchestrator.js';
import type { Race } from './race/types.js';
import { z } from 'zod';
import { Runner } from './runner/runner';
import { Race } from './race/types.js';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
Discriminated unions (the workhorse pattern)
Relay's Step, PromptStepOutput, InvocationEvent, and error class hierarchy all use discriminated unions. The pattern:
type Step =
| { kind: 'prompt'; id: string; spec: PromptStepSpec }
| { kind: 'script'; id: string; spec: ScriptStepSpec }
| { kind: 'branch'; id: string; spec: BranchStepSpec }
| { kind: 'parallel'; id: string; spec: ParallelStepSpec }
| { kind: 'terminal'; id: string; spec: TerminalStepSpec };
function execute(step: Step) {
switch (step.kind) {
case 'prompt': return executePrompt(step);
case 'script': return executeScript(step);
case 'branch': return executeBranch(step);
case 'parallel': return executeParallel(step);
case 'terminal': return executeTerminal(step);
default: assertNever(step);
}
}
function assertNever(x: never): never {
throw new Error(`Unhandled discriminant: ${JSON.stringify(x)}`);
}
The assertNever helper turns a missed case into a compile error.
Type narrowing
Prefer the type system's narrowing over as:
function isPromptStep(s: Step): s is Extract<Step, { kind: 'prompt' }> {
return s.kind === 'prompt';
}
if (err instanceof StepFailureError) {
console.log(err.stepId);
}
if ('schema' in step.spec.output) {
}
const promptStep = step as Extract<Step, { kind: 'prompt' }>;
Zod inference
Pair every schema with its inferred type — never write the type by hand and the schema separately:
import { z } from 'zod';
export const InventorySchema = z.object({
packages: z.array(z.object({
path: z.string(),
name: z.string(),
language: z.enum(['ts', 'py', 'go', 'rust', 'other']),
})),
});
export type Inventory = z.infer<typeof InventorySchema>;
Now Inventory and InventorySchema can never drift. If you change one, the compiler tells you.
noUncheckedIndexedAccess
Array and record access returns T | undefined:
const steps: Record<string, Step> = { ... };
const s = steps['inventory'];
s.spec;
const s = steps['inventory'];
if (s === undefined) throw new Error('missing step');
s.spec;
function requireStep(steps: Record<string, Step>, id: string): Step {
const s = steps[id];
if (!s) throw new FlowDefinitionError(`unknown step: ${id}`);
return s;
}
Branded types for IDs
When you have multiple ID-shaped strings (runId, runnerId, batonId), brand them so they can't be confused:
export type RunId = string & { readonly __brand: 'RunId' };
export type RunnerId = string & { readonly __brand: 'RunnerId' };
export function asRunId(s: string): RunId { return s as RunId; }
export function asRunnerId(s: string): RunnerId { return s as RunnerId; }
Now function loadRun(id: RunId) won't accept a RunnerId by mistake. (Use sparingly — only when the confusion has bitten.)
Async patterns
const config = await loadConfig();
await orchestrator.run(flow, input);
fetch(url).then(r => r.json()).then(processData);
const r = await fetch(url);
const data = await r.json();
processData(data);
const results = await Promise.allSettled(branches.map(executeBranch));
const failed = results.filter(r => r.status === 'rejected');
if (failed.length) throw new AggregateError(failed.map(f => f.reason));
When the type system is fighting you
The right response is almost never as or any. It's usually:
- Wrong type at the boundary. Refine the input type so downstream code doesn't need to narrow.
- Missing discriminator. Add a
kind field to make the union narrowable.
- Over-eager narrowing. Sometimes a function should accept the broader union and return per-variant.
- Genuine
unknown. Use unknown with a Zod schema to safely narrow at the boundary.
References
references/tsconfig-strict.md — what every compilerOptions flag does and why
references/esm-patterns.md — NodeNext gotchas, import.meta.url, dynamic imports, __dirname replacement
references/zod-patterns.md — Zod schema → type inference, refinement, transforms, parsing at boundaries