Skip to content
AI Full-Stack Wiki

Tutorial: Build a Ticket Triage CLI with TypeScript and Zod

Build a TypeScript CLI that validates model-shaped JSON with Zod, narrows content blocks, and loads files concurrently with deadlines and partial failure.

Difficulty: Beginner20 min readUpdated
On this page· What you will build

Why this matters

The TypeScript article explains why types check code and schemas check data. This tutorial makes you do it end to end on the kind of data an AI app handles every day: model responses made of content blocks, some of them wrong. You will build a small command-line tool that a support team could actually use. It reads saved model responses, extracts a ticket triage from each one, and says exactly why it rejected the rest. No API key is needed; the responses are files you create.

What you will build#

A CLI called triage that:

  • Lists a folder of saved model responses with an async iterator.
  • Loads them concurrently, each with its own deadline, and keeps whatever succeeds with Promise.allSettled.
  • Validates each one from unknown into a typed Triage with Zod, using a discriminated union for content blocks.
  • Prints valid triages by severity, and lists every failure with a precise reason.
  • Has tests with node:test and a strict typecheck.
Ticket triage CLI data flowThe CLI loads every saved model response at once, each with its own deadline. Zod turns each unknown JSON value into a typed triage or a readable error; valid triages are printed by severity and everything else is listed for a human.allSettledunknownResponsesresponses/*.jsonLoaderdeadline per fileZod schemasunknown → TriageReportsorted by severityNeeds a humanwith the reason

Hover or tap a component to see what it does.

Ticket triage CLI: model responses are loaded concurrently with per-file deadlines, validated from unknown JSON into typed triages with Zod, and either reported by severity or flagged for a human.
Show text description

Nothing is trusted until a schema has checked it: the loader returns unknown, and only the schemas produce a typed Triage.

  • Responses: Saved model responses: content blocks with a triage_ticket tool call, plus a few broken ones.
  • Loader: Reads all files concurrently with Promise.allSettled; AbortSignal.timeout gives each one a deadline.
  • Zod schemas: Validates the response envelope, narrows to the tool call, then validates its input.
  • Report: Valid triages, highest severity and most affected users first.
  • Needs a human: Timeouts, invalid JSON, missing tool calls, and values outside the schema, each with a precise reason.

You need Node.js 22.18 or later (it runs .ts files directly) and a terminal. Read Modern JavaScript and TypeScript first; this tutorial reuses its patterns without re-explaining them.

1. Set up the project#

  1. Create the folder and package.json

    mkdir triage && cd triage
    npm init -y
    npm pkg set type=module

    "type": "module" makes every .ts and .js file an ES module, which you need for import and top-level await.

  2. Install Zod and the TypeScript tooling

    npm install zod
    npm install -D typescript @types/node

    Node.js runs your TypeScript by stripping the types, so typescript is only used to check them.

  3. Add the scripts

    npm pkg set scripts.start="node src/main.ts responses" scripts.test="node --test" scripts.typecheck="tsc"
  4. Configure the compiler

    tsconfig.json
    {
      "compilerOptions": {
        "target": "es2024",
        "module": "nodenext",
        "strict": true,
        "noEmit": true,
        "allowImportingTsExtensions": true,
        "erasableSyntaxOnly": true,
        "verbatimModuleSyntax": true,
        "noUncheckedIndexedAccess": true,
        "skipLibCheck": true,
        "types": ["node"]
      },
      "include": ["src", "test"]
    }

Three of those options exist because Node.js runs the files directly. allowImportingTsExtensions lets you write import "./schema.ts", which is the path Node.js resolves. erasableSyntaxOnly rejects TypeScript features that need code generation (such as enum), which type stripping cannot run. verbatimModuleSyntax makes you mark type-only imports with import type, so nothing that exists only at compile time is left in the code Node.js runs. noUncheckedIndexedAccess makes array[i] possibly undefined, which is the truth.

2. Create sample model responses#

Each file is shaped like a Messages API response: an id, a stop_reason, and a content array of blocks. The model was asked to call a triage_ticket tool. Create a responses/ folder with these six files. Two are valid; the other four fail in different ways, on purpose.

responses/checkout-outage.json
{
  "id": "msg_01",
  "model": "claude-opus-5-5",
  "stop_reason": "tool_use",
  "content": [
    { "type": "thinking", "thinking": "Payments are failing for many users." },
    { "type": "text", "text": "Filing this as a high-severity ticket." },
    {
      "type": "tool_use",
      "id": "toolu_01",
      "name": "triage_ticket",
      "input": { "severity": "high", "summary": "Checkout fails with a 502 for card payments", "affected_users": 340 }
    }
  ]
}
responses/typo-on-pricing.json
{
  "id": "msg_02",
  "model": "claude-opus-5-5",
  "stop_reason": "tool_use",
  "content": [
    {
      "type": "tool_use",
      "id": "toolu_02",
      "name": "triage_ticket",
      "input": { "severity": "low", "summary": "Typo in the pricing page headline", "affected_users": 0 }
    }
  ]
}
responses/urgent-severity.json
{
  "id": "msg_03",
  "model": "claude-opus-5-5",
  "stop_reason": "tool_use",
  "content": [
    {
      "type": "tool_use",
      "id": "toolu_03",
      "name": "triage_ticket",
      "input": { "severity": "urgent", "summary": "Login emails are delayed", "affected_users": "12" }
    }
  ]
}
responses/no-tool-call.json
{
  "id": "msg_04",
  "model": "claude-opus-5-5",
  "stop_reason": "end_turn",
  "content": [{ "type": "text", "text": "I need more details before I can triage this ticket." }]
}
responses/truncated.json
{ "id": "msg_05", "content": [ { "type": "text", "te
responses/slow-dark-mode.json
{
  "id": "msg_06",
  "model": "claude-opus-5-5",
  "stop_reason": "tool_use",
  "content": [
    {
      "type": "tool_use",
      "id": "toolu_06",
      "name": "triage_ticket",
      "input": { "severity": "medium", "summary": "Dark mode toggle resets on reload", "affected_users": 25 }
    }
  ]
}

urgent-severity.json is the dangerous kind: perfectly valid JSON with wrong values, which is exactly what a model produces when a prompt or schema is loose. slow-dark-mode.json is valid, but the loader will pretend it comes from a slow server.

3. Define the schemas#

Write the schemas first and derive the types from them with z.infer. Defining both by hand works, but then nothing stops them drifting apart.

src/schema.ts
import { z } from "zod";
 
// The content blocks this program understands, defined once as schemas.
// z.infer derives the TypeScript types, so types and runtime checks can never disagree.
const TextBlock = z.object({ type: z.literal("text"), text: z.string() });
const ThinkingBlock = z.object({ type: z.literal("thinking"), thinking: z.string() });
const ToolUseBlock = z.object({ type: z.literal("tool_use"), id: z.string(), name: z.string(), input: z.unknown() });
 
export const ContentBlock = z.discriminatedUnion("type", [TextBlock, ThinkingBlock, ToolUseBlock]);
export type ContentBlock = z.infer<typeof ContentBlock>;
 
export const ModelResponse = z.object({
  id: z.string(),
  stop_reason: z.string().nullable(),
  content: z.array(ContentBlock),
});
export type ModelResponse = z.infer<typeof ModelResponse>;
 
// What a triage_ticket tool call must contain.
export const Triage = z.object({
  severity: z.enum(["low", "medium", "high"]),
  summary: z.string().trim().min(1).max(280),
  affected_users: z.number().int().nonnegative(),
});
export type Triage = z.infer<typeof Triage>;

z.discriminatedUnion("type", ...) reads the type field first and validates against the matching schema, which gives much clearer errors than trying every option. Note that a tool call's input is z.unknown(): the envelope schema does not pretend to know what each tool receives. That check happens separately, once you know which tool was called.

4. Narrow content blocks#

The inferred ContentBlock type is a discriminated union, so switch (block.type) narrows each branch to its exact shape. The never assignment makes the compiler enforce that every block type is handled.

src/blocks.ts
import type { ContentBlock } from "./schema.ts";
 
export type ToolUseBlock = Extract<ContentBlock, { type: "tool_use" }>;
 
/** One-line description of a block, for logs. */
export function describe(block: ContentBlock): string {
  switch (block.type) {
    case "text":
      return `text "${block.text.length > 40 ? `${block.text.slice(0, 40).trimEnd()}…` : block.text}"`;
    case "thinking":
      return "thinking";
    case "tool_use":
      return `tool call: ${block.name}`;
    default: {
      // Add a block type to ContentBlock and this line stops compiling until you handle it.
      const unreachable: never = block;
      return unreachable;
    }
  }
}
 
/** The first call to the named tool, typed as a tool_use block. */
export function findToolCall(blocks: ContentBlock[], name: string): ToolUseBlock | undefined {
  return blocks.find((b): b is ToolUseBlock => b.type === "tool_use" && b.name === name);
}

Extract<ContentBlock, { type: "tool_use" }> derives the tool-use member from the union instead of redefining it. The callback passed to find is a type predicate (b is ToolUseBlock), so the result is typed as a tool call, not as any block.

5. Validate one response#

This function is the heart of the program and contains no I/O, which makes it easy to test. It takes unknown and returns a Result: either a value or a readable error, never an exception.

src/triage.ts
import type { z } from "zod";
import { describe, findToolCall } from "./blocks.ts";
import { ModelResponse, Triage } from "./schema.ts";
 
export type Result<T> = { ok: true; value: T } | { ok: false; error: string };
 
const formatIssues = (error: z.ZodError) =>
  error.issues.map((i) => `${i.path.map(String).join(".") || "(root)"}: ${i.message}`).join("; ");
 
/** Turn an untrusted, parsed JSON value into a validated triage, or a readable error. */
export function triageResponse(raw: unknown): Result<Triage> {
  const response = ModelResponse.safeParse(raw);
  if (!response.success) return { ok: false, error: `not a model response (${formatIssues(response.error)})` };
 
  const { content, stop_reason } = response.data;
  const call = findToolCall(content, "triage_ticket");
  if (!call) {
    return { ok: false, error: `no triage_ticket call (stop_reason: ${stop_reason}; got ${content.map(describe).join(", ")})` };
  }
 
  const triage = Triage.safeParse(call.input);
  if (!triage.success) return { ok: false, error: formatIssues(triage.error) };
  return { ok: true, value: triage.data };
}

Validation happens in two stages: first the envelope (is this a model response at all?), then the tool input (did the model fill in the tool correctly?). Each stage reports its own kind of error, which is what lets the CLI tell a human why a response was rejected.

6. Load files concurrently, with deadlines#

In a real app these responses would come over the network. The loader simulates that: files whose names start with slow- take 2 seconds, the rest 200 ms. It accepts an AbortSignal, so the caller decides how long to wait.

src/load.ts
import { readFile } from "node:fs/promises";
import { basename } from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
 
/**
 * Stand-in for fetching a model response over the network: files named slow-* take 2 seconds,
 * the rest 200 ms. The signal cancels both the wait and the read.
 */
export async function loadResponse(path: string, signal: AbortSignal): Promise<unknown> {
  try {
    await sleep(basename(path).startsWith("slow-") ? 2_000 : 200, undefined, { signal });
    const text = await readFile(path, { encoding: "utf8", signal });
    return JSON.parse(text);
  } catch (error) {
    // Node's built-ins reject with a generic AbortError; rethrow the signal's own reason
    // (a TimeoutError for AbortSignal.timeout) so callers can tell why it stopped.
    throw signal.aborted ? signal.reason : error;
  }
}

The return type is Promise<unknown>, not Promise<any>. JSON.parse returns any, which would let every later line use the value unchecked; declaring unknown forces it through the schemas first.

7. Write the CLI#

The entry point lists the folder, starts every load at once, and sorts the outcomes into two lists.

src/main.ts
import { opendir } from "node:fs/promises";
import { basename, join } from "node:path";
import { loadResponse } from "./load.ts";
import type { Triage } from "./schema.ts";
import { triageResponse } from "./triage.ts";
 
const dir = process.argv[2] ?? "responses";
const DEADLINE_MS = 1_000;
const RANK = { high: 0, medium: 1, low: 2 } satisfies Record<Triage["severity"], number>;
 
// A directory handle is an async iterable: entries arrive one at a time.
const files: string[] = [];
for await (const entry of await opendir(dir)) {
  if (entry.isFile() && entry.name.endsWith(".json")) files.push(join(dir, entry.name));
}
files.sort();
 
// Load every file at once, each with its own deadline, and keep whatever succeeds.
const started = performance.now();
const settled = await Promise.allSettled(files.map((file) => loadResponse(file, AbortSignal.timeout(DEADLINE_MS))));
const elapsed = Math.round(performance.now() - started);
 
const triaged: { file: string; triage: Triage }[] = [];
const failed: { file: string; reason: string }[] = [];
 
settled.forEach((outcome, i) => {
  const file = basename(files[i]!);
  if (outcome.status === "rejected") {
    const error = outcome.reason as Error;
    const reason =
      error.name === "TimeoutError" ? `timed out after ${DEADLINE_MS} ms`
      : error instanceof SyntaxError ? "not valid JSON"
      : error.message;
    failed.push({ file, reason });
    return;
  }
  const result = triageResponse(outcome.value);
  if (result.ok) triaged.push({ file, triage: result.value });
  else failed.push({ file, reason: result.error });
});
 
triaged.sort((a, b) => RANK[a.triage.severity] - RANK[b.triage.severity] || b.triage.affected_users - a.triage.affected_users);
 
console.log(`Triaged ${triaged.length} of ${files.length} responses in ${elapsed} ms\n`);
for (const { file, triage } of triaged) {
  const users = `${triage.affected_users} users`.padStart(10);
  console.log(`${triage.severity.toUpperCase().padEnd(6)} ${users}  ${triage.summary}  (${file})`);
}
if (failed.length > 0) {
  console.log(`\nNeeds a human (${failed.length}):`);
  for (const { file, reason } of failed) console.log(`  ${file}: ${reason}`);
}
process.exitCode = failed.length > 0 ? 1 : 0;

Two details are worth noticing. satisfies Record<Triage["severity"], number> checks that RANK has exactly one entry per severity, while keeping its literal type; add a severity to the schema and this line fails to compile until you rank it. And process.exitCode = 1 when anything failed lets scripts and CI react to the result without killing the process mid-output.

Checkpoint. Run it:

npm start
Triaged 2 of 6 responses in 1002 ms
 
HIGH    340 users  Checkout fails with a 502 for card payments  (checkout-outage.json)
LOW       0 users  Typo in the pricing page headline  (typo-on-pricing.json)
 
Needs a human (4):
  no-tool-call.json: no triage_ticket call (stop_reason: end_turn; got text "I need more details before I can triage…")
  slow-dark-mode.json: timed out after 1000 ms
  truncated.json: not valid JSON
  urgent-severity.json: severity: Invalid option: expected one of "low"|"medium"|"high"; affected_users: Invalid input: expected number, received string

The whole run takes about one second, which is the deadline. Loading the same files one after another would take about two (five 200 ms loads plus the one-second timeout). Each failure has a different cause, and each one is reported precisely instead of crashing the program.

8. Test and typecheck#

Because triageResponse is pure, its tests need no files and no waiting:

test/triage.test.ts
import assert from "node:assert/strict";
import { test } from "node:test";
import { triageResponse } from "../src/triage.ts";
 
const response = (input: unknown) => ({
  id: "msg_test",
  stop_reason: "tool_use",
  content: [{ type: "tool_use", id: "toolu_test", name: "triage_ticket", input }],
});
 
test("accepts a valid triage and trims the summary", () => {
  const result = triageResponse(response({ severity: "high", summary: "  Checkout is down  ", affected_users: 12 }));
  assert.deepEqual(result, { ok: true, value: { severity: "high", summary: "Checkout is down", affected_users: 12 } });
});
 
test("rejects a severity outside the enum", () => {
  const result = triageResponse(response({ severity: "urgent", summary: "Slow emails", affected_users: 1 }));
  assert.ok(!result.ok && result.error.startsWith("severity:"));
});
 
test("rejects a number sent as a string", () => {
  const result = triageResponse(response({ severity: "low", summary: "Typo", affected_users: "12" }));
  assert.ok(!result.ok && result.error.startsWith("affected_users:"));
});
 
test("explains a response with no tool call", () => {
  const result = triageResponse({ id: "msg_test", stop_reason: "end_turn", content: [{ type: "text", text: "Need more info" }] });
  assert.ok(!result.ok && result.error.includes("stop_reason: end_turn"));
});
 
test("rejects values that are not model responses", () => {
  assert.equal(triageResponse(null).ok, false);
  assert.equal(triageResponse({ id: "msg_test", stop_reason: null, content: "hello" }).ok, false);
});
npm test         # node --test finds test/*.test.ts: 5 passing
npm run typecheck

Now prove the exhaustiveness check works. Add a fourth block type to src/schema.ts and include it in the union:

src/schema.ts
const RedactedThinkingBlock = z.object({ type: z.literal("redacted_thinking"), data: z.string() });
 
export const ContentBlock = z.discriminatedUnion("type", [TextBlock, ThinkingBlock, ToolUseBlock, RedactedThinkingBlock]);
npm run typecheck
# src/blocks.ts(16,13): error TS2322: Type '{ type: "redacted_thinking"; data: string; }' is not assignable to type 'never'.

The compiler points at the exact switch that forgot the new case. Add a case "redacted_thinking": branch to describe and the error disappears.

Common mistakes#

  • Writing types and schemas separately. They drift: someone adds a field to the type, the schema still strips it, and the bug shows up at runtime. Derive types with z.infer.
  • Returning any from a loader. JSON.parse returns any, and any spreads silently through everything it touches. Declare the boundary as unknown.
  • Using Promise.all when partial results are useful. One timeout would reject the whole batch and discard five good answers. Use Promise.allSettled and report each outcome.
  • Checking error.name === "AbortError" and calling it a timeout. A user cancelling also aborts. Use AbortSignal.timeout() and inspect signal.reason.
  • Throwing from validation. parse throws; safeParse returns a result you can report. At a boundary you almost always want the error as data.

Try it

Extend the triage CLI

  1. Add a --deadline=2500 flag (read it from process.argv) and confirm slow-dark-mode.json now succeeds.
  2. Add the redacted_thinking block from step 8 permanently, handle it in describe, and add a response file that uses it.
  3. Change affected_users to z.coerce.number().int().nonnegative() and rerun. urgent-severity.json now fails only on severity. Write one sentence on whether coercion is a good idea for model output.
  4. Add a --json flag that prints { triaged, failed } as JSON instead of text, so another program can consume it.

Check your understanding

Why does loadResponse return Promise<unknown> instead of Promise<any>?
Six files load concurrently; one would take 2 seconds and the deadline is 1 second. What does Promise.allSettled give you?
What does the never assignment in describe's default case protect against?

Key takeaways

  • Define schemas once and derive types with z.infer; the types and the runtime checks then cannot disagree.
  • Treat every value that crosses a boundary as unknown, and validate it in stages that each report their own kind of error.
  • Discriminated unions plus an exhaustive never check turn "we added a block type" into a compile error instead of a production bug.
  • Promise.allSettled with AbortSignal.timeout() gives you concurrency, deadlines, and partial results in a few lines.
  • Keep validation pure so it can be tested without I/O.

Next steps#

Finished this article? Track it to see your progress on the roadmap.