
In our previous tutorial on rendering thinking and loading states in React when your AI agent is reasoning, we built a collapsible AgentThinkingTimeline that tracks every latency phase from initial routing to post-tool synthesis. While building that component, you may have noticed a subtle tension in our frontend code: when useChat streams back m.toolInvocations or m.annotations, their TypeScript types default to loose unknown or generic JSONValue shapes, tempting developers to reach for as any or as unknown as MyType just to silence the compiler.
To keep an entire AI agent system end-to-end type-safe in TypeScript, you establish a Single Source of Truth using Zod schemas and propagate inferred types (z.infer<typeof schema>) across four critical boundaries: tool input validation, discriminated union tool return envelopes ({ ok: true, data: T } | { ok: false, agentInstruction: string }), structured LLM outputs via generateObject, and custom TypeScript type-predicate guards (isToolInvocationFor) in your React UI. This architecture eliminates unsafe as type assertions and guarantees that compile-time IDE autocomplete matches runtime LLM behavior.
This post completes Phase 2 (Posts 17–21) of our hands-on DevPulse implementation roadmap. Before we move on to production rate limiting, SSE, and Supabase persistence in Phase 3, we are going to lock down every boundary of our agent so a malformed LLM output or failed API call can never crash our Next.js server or React frontend at runtime.
The Core Paradox: Probabilistic LLMs vs. Static TypeScript
In a standard full-stack Next.js application, you control both ends of the wire. Your Prisma or Supabase database has a fixed schema, your Server Action returns a known TypeScript interface, and your React component renders it. TypeScript's compiler can verify the entire chain before you deploy.
An AI agent breaks that assumption because a Large Language Model is a probabilistic runtime generator. At runtime, OpenAI or Claude generates raw JSON strings for tool arguments, invokes tools dynamically by name, and streams heterogeneous arrays of text, tool calls, and metadata annotations back to the browser.
If you rely on compile-time interface declarations alone—or worse, sprinkle toolInvocation.result as GitHubIssueDTO across your JSX—TypeScript will give you a green build even when your code is one unexpected LLM output away from a TypeError: Cannot read properties of undefined crash. True type safety in an agentic system requires pairing compile-time type inference with runtime schema narrowing.

The 4 Type Boundaries of a Full-Stack AI Agent
To make DevPulse 100% type-safe from the LLM prompt all the way down to the React DOM, we need to close the type gap at four specific architectural boundaries:
- Boundary 1 — Tool Input Contract (LLM -> Server Executor): Deriving static TypeScript types directly from Zod schemas using
z.inferso you never maintain duplicateinterfacedefinitions that drift out of sync with your prompt schemas. - Boundary 2 — Discriminated Tool Output Envelopes (Server Executor -> Orchestrator): Using a strict discriminated union (
ok: truevs.ok: false) mapped to each tool's exact name in a centralToolContractMapso neither the server nor the UI can accessresult.datawithout first checkingif (result.ok). - Boundary 3 — Structured LLM Classification (
generateObject): When your backend needs the LLM to return a structured decision—such as classifying a bug report's severity and target subsystem before routing—usinggenerateObjectwith a Zod schema instead of parsing markdown prose with brittle regular expressions. - Boundary 4 — UI Narrowing Type Guards (Stream -> React JSX): Writing reusable TypeScript type predicates (
inv is TypedToolInvocation<'inspectLiveGitHubIssue'>) that narrow generic Vercel AI SDKToolInvocationobjects into strongly typed tool cards in React.
Here is how a single Zod schema contract flows across all four boundaries to provide both compile-time and runtime safety:
`mermaid flowchart TD ZodContract[Single Source of Truth: Zod Schemas in contracts.ts] -->|z.infer Compile-Time + .safeParse Runtime| B1[Boundary 1: Tool Input Validation] ZodContract -->|Mapped ToolContractMap + Discriminated Union| B2[Boundary 2: Tool Output Envelope ok: true or false] ZodContract -->|generateObject Schema Enforcement| B3[Boundary 3: Structured LLM Triage Output] ZodContract -->|Type Predicate Narrowing Guard| B4[Boundary 4: React UI ToolInvocation Autocomplete]
B1 --> Runtime[Zero 'any' Casts & Zero Runtime TypeError Crashes]
B2 --> Runtime
B3 --> Runtime
B4 --> Runtime
`
Architecture Flow Summary: The central Zod schema registry (contracts.ts) acts as a single source of truth across all four system boundaries: validating tool inputs via z.infer and .safeParse (Boundary 1), enforcing discriminated union output envelopes in ToolContractMap (Boundary 2), constraining structured LLM classifications via generateObject (Boundary 3), and narrowing React ToolInvocation props via custom TypeScript type guards (Boundary 4).
Step 1: Creating the Master ToolContractMap and Type Guards (type-contracts.ts)
In Posts 12 and 19, we wrote Zod input schemas (searchDocsSchema, checkIssuesSchema, draftTicketSchema, and inspectGitHubIssueSchema). However, our tool outputs were typed loosely as unknown inside AgentStepTrace.
Let us create src/lib/agent/type-contracts.ts. This file uses TypeScript's Mapped Types and Discriminated Unions to bind every tool name (ToolName) to both its exact Zod input schema and its exact strongly typed output DTO—plus a custom type-predicate guard for React:
`typescript // src/lib/agent/type-contracts.ts import { z } from 'zod'; import { type ToolInvocation } from 'ai'; import { searchDocsSchema, checkIssuesSchema, draftTicketSchema, } from './blueprint'; import { inspectGitHubIssueSchema, type SanitizedGitHubIssueDTO, } from './custom-github-tool';
export type SuccessEnvelope = { ok: true; toolName: ToolName; durationMs: number; data: T; };
export type ErrorEnvelope = { ok: false; toolName: ToolName; retryable: boolean; durationMs: number; data: null; agentInstruction: string; };
export type ToolResultEnvelope = SuccessEnvelope | ErrorEnvelope;
export interface SearchDocsOutputDTO { queryUsed: string; topK: number; matchedChunks: Array<{ docPath: string; score: number; excerpt: string; }>; }
export interface CheckIssuesOutputDTO { keyword: string; statusFilter: 'open' | 'closed' | 'all'; issues: Array<{ id: string; title: string; status: 'open' | 'closed'; component: string; }>; }
export interface DraftTicketOutputDTO { draftId: string; status: 'awaiting_user_approval'; ticketPreview: z.infer; }
export interface ToolContractRegistry { searchKnowledgeBase: { input: z.infer; output: ToolResultEnvelope; }; checkIssueTracker: { input: z.infer; output: ToolResultEnvelope; }; inspectLiveGitHubIssue: { input: z.infer; output: ToolResultEnvelope; }; draftIssueTicket: { input: z.infer; output: ToolResultEnvelope; }; }
export type ToolName = keyof ToolContractRegistry; export type ToolInput = ToolContractRegistry[K]['input']; export type ToolOutput = ToolContractRegistry[K]['output'];
export type TypedAgentStepTrace = { [K in ToolName]: { stepIndex: number; thoughtSummary: string; selectedTool: K; toolInput: ToolInput; toolOutput: ToolOutput; durationMs: number; }; }[ToolName];
export type NarrowedToolInvocation = ( | { state: 'partial-call' | 'call'; toolCallId: string; toolName: K; args: Partial<ToolInput>; } | { state: 'result'; toolCallId: string; toolName: K; args: ToolInput; result: ToolOutput; } ) & ToolInvocation;
export function isToolInvocationFor( invocation: ToolInvocation, expectedToolName: K ): invocation is NarrowedToolInvocation { return invocation.toolName === expectedToolName; } `
Take a moment to appreciate what ToolContractRegistry and isToolInvocationFor just did for your codebase:
- Discriminated Union Narrowing (
ToolResultEnvelope<T>): BecauseSuccessEnvelope<T>hasok: true(withdata: T) andErrorEnvelopehasok: false(withdata: nullandagentInstruction: string), TypeScript will refuse to compile if you try to accessresult.data.titlebefore checkingif (result.ok). - Zero-Cast UI Narrowing (
isToolInvocationFor): When you iterate overm.toolInvocationsin React and checkif (isToolInvocationFor(inv, 'inspectLiveGitHubIssue')), TypeScript automatically narrowsinv.argsto{ repository, issueNumber }and—onceinv.state === 'result'—narrowsinv.resulttoToolResultEnvelope<SanitizedGitHubIssueDTO>.
Step 2: Enforcing Type-Safe Structured LLM Outputs (structured-triage.ts)
What about when you need the LLM itself to return a structured TypeScript object—for example, classifying a user's bug report into a severity level, affected component, and recommended diagnostic tool before starting a workflow?
Instead of asking the LLM in a system prompt to 'please reply in valid JSON' and running JSON.parse(text) as MyType, use the Vercel AI SDK's generateObject function with a Zod schema. Let us create src/lib/agent/structured-triage.ts:
`typescript // src/lib/agent/structured-triage.ts import 'server-only'; import { generateObject } from 'ai'; import { createAnthropic } from '@ai-sdk/anthropic'; import { z } from 'zod';
export const bugTriageClassificationSchema = z.object({ category: z.enum([ 'rate-limiting', 'authentication', 'database-connection', 'ui-rendering', 'general-question', ]), estimatedSeverity: z.enum(['low', 'medium', 'high', 'critical']), requiresExternalGitHubCheck: z.boolean(), recommendedFirstTool: z.enum([ 'searchKnowledgeBase', 'checkIssueTracker', 'inspectLiveGitHubIssue', ]), extractedKeywords: z.array(z.string()).min(1).max(4), reasoningSummary: z.string().max(200), });
export type BugTriageClassification = z.infer< typeof bugTriageClassificationSchema
;
export async function classifyDeveloperQuery( userQuery: string ): Promise { const apiKey = process.env.ANTHROPIC_API_KEY; if (!apiKey) { throw new Error('Missing ANTHROPIC_API_KEY in server environment.'); }
const anthropic = createAnthropic({ apiKey });
const { object } = await generateObject({ model: anthropic('claude-3-5-sonnet-latest'), schema: bugTriageClassificationSchema, temperature: 0, system: 'Classify incoming developer support queries into strict triage metadata for the DevPulse agent router.', prompt: userQuery, });
return object; } `
Because generateObject passes bugTriageClassificationSchema directly to the model's native structured-output grammar and validates the returned payload with Zod before resolving, object is guaranteed to match BugTriageClassification at both compile time and runtime.
Step 3: Rendering Strongly Typed Tool Invocations in React Without any
Now let us build src/components/TypeSafeToolRenderer.tsx to see how clean our React UI code becomes when we use isToolInvocationFor instead of unsafe as casts. Every single property on inv.args and inv.result.data has 100% IDE autocomplete.
`tsx // src/components/TypeSafeToolRenderer.tsx 'use client';
import { type ToolInvocation } from 'ai'; import { isToolInvocationFor } from '@/lib/agent/type-contracts';
interface TypeSafeToolRendererProps { invocation: ToolInvocation; }
export default function TypeSafeToolRenderer({
invocation,
}: TypeSafeToolRendererProps) {
if (isToolInvocationFor(invocation, 'inspectLiveGitHubIssue')) {
if (invocation.state !== 'result') {
return (
⚡ Inspecting GitHub: {invocation.args.repository ?? '...'}{' '}
{invocation.args.issueNumber ? #${invocation.args.issueNumber} : ''}
);
}
const envelope = invocation.result;
if (!envelope.ok) {
return (
<div className='p-3 rounded-lg bg-amber-950/40 border border-amber-800/60 text-xs text-amber-200'>
⚠ GitHub Tool Alert ({envelope.durationMs}ms): {envelope.agentInstruction}
</div>
);
}
const issue = envelope.data;
return (
<div className='p-3.5 rounded-lg bg-slate-950 border border-cyan-800/60 text-xs space-y-1.5'>
<div className='flex items-center justify-between font-mono'>
<span className='text-cyan-400 font-semibold'>
{issue.repository} #{issue.issueNumber}
</span>
<span className='text-emerald-400 uppercase text-[10px]'>
{issue.state} • { issue.commentsCount } comments
</span>
</div>
<div className='text-slate-100 font-medium'>{issue.title}</div>
<p className='text-slate-400 line-clamp-2'>{issue.bodyExcerpt}</p>
</div>
);
}
if (isToolInvocationFor(invocation, 'searchKnowledgeBase')) { if (invocation.state !== 'result') { return ( 🔍 Searching Docs for: '{invocation.args.query ?? '...'}' ); }
const envelope = invocation.result;
if (!envelope.ok) {
return (
<div className='p-3 rounded-lg bg-red-950/40 border border-red-800 text-xs text-red-200'>
Docs search failed: {envelope.agentInstruction}
</div>
);
}
return (
<div className='p-3 rounded-lg bg-slate-950 border border-purple-800/60 text-xs space-y-1'>
<div className='text-purple-400 font-mono font-semibold'>
📚 Retrieved {envelope.data.matchedChunks.length} doc chunk(s) for '{envelope.data.queryUsed}'
</div>
{envelope.data.matchedChunks.map((chunk) => (
<div key={chunk.docPath} className='text-slate-300 truncate'>
• {chunk.docPath} (Score: {chunk.score})
</div>
))}
</div>
);
}
return null; } `
Look at the inspectLiveGitHubIssue branch above: if you accidentally try to write envelope.data.title above the if (!envelope.ok) check, TypeScript immediately underlines envelope.data in red because envelope.data is null when ok is false! You cannot deploy a null-pointer bug even by accident.

A Real Debugging Lesson: How One Lazy as Cast Crashed My Chat UI on a 404 Tool Call
When I was building the modal detail views in Movie Explorer and the issue cards in my GitHub Issues Tracker project, I learned early on to guard against missing API fields. Yet when I first wired up custom tool cards in an early build of DevPulse, I took a shortcut that bit me immediately.
Inside my React message loop, toolInvocation.result was typed as any (or unknown) by default. Because I knew my inspectLiveGitHubIssue tool returned a SanitizedGitHubIssueDTO on success, I lazily wrote const issue = toolInvocation.result as SanitizedGitHubIssueDTO; and immediately rendered issue.labels.map(...) inside the JSX. The TypeScript compiler was completely happy (tsc --noEmit passed with zero errors), and my happy-path tests with valid GitHub issue numbers worked fine.
Ten minutes later, I tested the agent with a nonexistent issue number (#999999) to see how it handled a 404. On the server, my custom tool caught the 404 gracefully and returned { ok: false, data: null, agentInstruction: 'Issue does not exist' }. The millisecond that tool result streamed to the browser, my React component cast that error envelope as SanitizedGitHubIssueDTO, tried to evaluate issue.labels.map on undefined, and crashed the entire Next.js client tree with an unhandled runtime TypeError!
Replacing that as cast with the ToolResultEnvelope<T> discriminated union (ok: true vs. ok: false) and the isToolInvocationFor type guard in type-contracts.ts immediately highlighted three places in my UI where I had forgotten to handle tool failure states or partial-call undefined arguments. Once tsc --noEmit passed with the discriminated union in place, the runtime crashes vanished for good.
Common Mistakes When Typing AI Agent Systems in TypeScript
As we wrap up Phase 2 of our implementation series, audit your repository against these four TypeScript anti-patterns:
1. Using JSON.parse(text) as MyType on LLM Outputs
TypeScript's as keyword performs zero runtime validation; it merely tells the compiler to stop checking. If an LLM hallucinates an extra markdown backtick or omits a required array field, JSON.parse(text) as MyType lies to your application and triggers a crash downstream. Always parse untrusted LLM or external API payloads with schema.safeParse() or generateObject().
2. Maintaining Separate interface Declarations and Zod Schemas
If you write export interface SearchDocsArgs { query: string; limit: number } in one file and export const searchDocsSchema = z.object({ query: z.string(), topK: z.number() }) in another, your TypeScript interface (limit) and runtime schema (topK) will inevitably drift apart. Always define the Zod schema first and derive the TypeScript type via export type SearchDocsArgs = z.infer<typeof searchDocsSchema>.
3. Using Bag-of-Optionals Instead of Discriminated Unions
Typing a tool result as { ok: boolean; data?: GitHubIssueDTO; error?: string } is an anti-pattern because even after you check if (result.ok), TypeScript still thinks result.data might be undefined! Using a strict discriminated union where ok: true guarantees data: T and ok: false guarantees agentInstruction: string gives you exact type narrowing.
4. Forgetting That invocation.args Is Partial During Streaming
While invocation.state === 'partial-call', the LLM is still streaming the JSON parameters token by token, meaning invocation.args.repository or invocation.args.query can be undefined for a few hundred milliseconds. Notice how our NarrowedToolInvocation type explicitly models args: Partial<ToolInput<K>> during 'partial-call' | 'call' and only promotes args to full ToolInput<K> when state === 'result'.
Frequently Asked Questions
Does adding Zod schemas and TypeScript type guards slow down my AI agent's streaming performance?
No. TypeScript types (ToolContractRegistry, z.infer, and type predicates like isToolInvocationFor) are completely erased at compile time and add zero bytes or milliseconds at runtime. Meanwhile, runtime Zod .safeParse() validation on a 5-field tool object takes less than 0.05 milliseconds on Node.js—completely negligible compared to a 400ms LLM network call.
Why use z.infer<typeof schema> instead of writing standard TypeScript interfaces first?
Large Language Models require a runtime JSON Schema (which OpenAI and Anthropic read to format tool calls) and a runtime validator (to reject hallucinated parameters). A TypeScript interface disappears entirely when your code compiles to JavaScript, so it cannot be sent to OpenAI or validate incoming JSON. Defining the Zod schema first gives you both the runtime JSON Schema for the LLM and the static TypeScript type via z.infer from a single definition.
How do I type custom message.annotations in the Vercel AI SDK useChat hook?
Because message.annotations is typed as JSONValue[] | undefined by the SDK, the safest pattern is to validate or narrow each annotation with a small Zod schema or type predicate (function isTelemetryAnnotation(a: unknown): a is MessageTelemetryAnnotation) before rendering its properties in JSX, ensuring that unexpected stream metadata never breaks your component.
Need a High-Performance Web App or Custom AI Solution?
I help founders, businesses, and engineering teams build lightning-fast web applications, resilient backend architectures, and intelligent AI workflows. Have an idea in mind? Let’s bring it to life.
Found this article helpful?
Give some claps to support more in-depth engineering logs!
Zahid Hasan Tonmoy
Author & Developer
MERN Full Stack Developer & AI Agent Developer based in Dhaka, Bangladesh. Writing about web development, React, PostgreSQL and my learning journey.