Building Type-Safe AI Integrations: Validating LLM Output with TypeScript and Zod

The first time I wired an LLM call into a production TypeScript service, I made an assumption that cost me a debugging afternoon: I trusted the response to come back in the shape I asked for. It mostly did. "Mostly" is not a word you want anywhere near production code, and the gap between "mostly" and "always" is exactly where AI integrations quietly break.
The core problem: LLMs don't have a type system, your code does
A REST API with a defined contract fails loudly and predictably when something's wrong, wrong status code, malformed JSON, an error you can catch. An LLM call can return something that's valid JSON, matches most of your expected shape, and is still subtly wrong: a field that's usually a number coming back as a string, an enum value that's close but not quite one of your defined options, a field silently missing because the model decided it wasn't relevant this time.
typescript
interface FraudAssessment {
riskScore: number;
flags: string[];
recommendation: "approve" | "review" | "decline";
}
const response = await llmClient.assess(transaction);
const assessment: FraudAssessment = JSON.parse(response); // trusting blindly
That type annotation is a lie the moment the actual JSON doesn't match. TypeScript's type system checks your code at compile time. It has no way to verify that a string parsed from an LLM response actually conforms to the shape you've declared.
Zod: validation that matches your types instead of trusting them
typescript
import { z } from "zod";
const FraudAssessmentSchema = z.object({
riskScore: z.number().min(0).max(1),
flags: z.array(z.string()),
recommendation: z.enum(["approve", "review", "decline"]),
});
type FraudAssessment = z.infer<typeof FraudAssessmentSchema>;
const parsed = JSON.parse(response);
const result = FraudAssessmentSchema.safeParse(parsed);
if (!result.success) {
logger.error("LLM response failed validation", { errors: result.error.issues });
return fallbackAssessment();
}
const assessment: FraudAssessment = result.data;
Deriving the TypeScript type from the Zod schema with z.infer means the compile-time type and the runtime validation can never silently drift apart, because there's only one source of truth instead of two. This single change turned "the model probably returned what we expected" into "we know exactly what we got, and if it's wrong, we know precisely how."
Fallback behavior matters more than perfect prompting
You can spend real effort tightening a prompt to reduce malformed responses, and you should. But no amount of prompt engineering gets you to zero malformed responses, because the model is fundamentally probabilistic, not deterministic. What actually matters more than prompt perfection is having a defined, tested fallback for validation failure: a conservative default decision, a retry with a stricter prompt, an escalation to human review. On a fraud detection integration, the fallback for a failed validation was always the more conservative outcome, flagging for review rather than silently approving, because the cost of that failure mode was asymmetric.
Partial validation for streaming responses
Streaming LLM output complicates this further, since you're validating an incomplete JSON structure as it arrives. For streaming cases, I validate at natural completion boundaries, a completed field, a completed array element, rather than attempting to validate a JSON fragment that isn't syntactically complete yet.
typescript
const partialSchema = FraudAssessmentSchema.partial();
const partialResult = partialSchema.safeParse(accumulatedSoFar);
Logging the raw response, always
When validation fails, I always log the raw, unparsed response alongside the validation errors. Prompt behavior drifts over time, model versions change, and having the actual raw output is what lets you distinguish "the model got worse at this task" from "our schema was too strict" when you're debugging a spike in validation failures three weeks later.
The real discipline
Treating an LLM response with the same skepticism you'd apply to unvalidated user input, not the trust you'd extend to your own internal service, is the entire lesson here. The model is powerful and useful. It is not your type system, and pretending otherwise is how a "mostly correct" response becomes a production incident.


