Skip to main content

Command Palette

Search for a command to run...

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

Updated
4 min readView as Markdown
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.

6 views
B
BotSailor4h ago

This is a really practical take on a problem that’s easy to underestimate with LLM integrations. I especially liked the point that a TypeScript interface doesn’t actually protect you at runtime when the data is coming from a model.

Using Zod as the single source of truth makes a lot of sense, but the part about fallback behavior stood out to me even more. Models are probabilistic, so designing for validation failures instead of assuming they won’t happen feels like the more realistic production mindset.

The section on streaming validation and logging the raw response was also a great addition. These are the kinds of details that become really valuable when an AI feature moves from a demo into a real system. Great read!