Enforcing TypeScript Types in LLM Outputs
Large Language Models are powerful, but their outputs are inherently unpredictable. When you ask an LLM to return data that your application needs to consume—say, a user profile, a list of products, or a structured analysis—you're at the mercy of probabilistic text generation. A model might return valid JSON 95% of the time, but that remaining 5% can crash your application, corrupt your database, or produce confusing user experiences. Enforcing TypeScript types on LLM outputs bridges the gap between unstructured AI generation and the strict, predictable contracts that production software demands.
What Does "Enforcing Types" Actually Mean?
Enforcing TypeScript types in LLM outputs means guaranteeing that the data returned by a model conforms to a predefined schema at runtime, not just at compile time. TypeScript itself is erased at runtime—your interface definitions vanish when the code runs. So when an LLM returns a JSON string, TypeScript cannot protect you. You need a runtime validation layer that parses the LLM output, checks it against a schema, and either accepts it or rejects it with actionable errors.
The typical workflow looks like this:
- Define a schema that describes the shape of the data you want
- Send that schema to the LLM as part of the prompt or via a structured output API
- Receive the model's response
- Validate the response against the schema at runtime
- Retry, repair, or fail gracefully if validation fails
Why It Matters
Without type enforcement, you end up writing defensive code everywhere. You check whether fields exist, whether they're the right type, whether arrays are actually arrays. This clutters your codebase and still leaves gaps. With a proper enforcement strategy, you get a single validation boundary, clear error messages, and the ability to feed validation errors back to the model for automatic correction. This is especially critical in agentic workflows where one model's output becomes another model's input—garbage in, garbage out compounds quickly.
The Core Problem: LLMs Don't Return Typed Data
Consider a naive approach where you ask an LLM to return JSON and parse it directly:
import OpenAI from "openai";
const openai = new OpenAI();
interface MovieReview {
title: string;
rating: number;
summary: string;
tags: string[];
}
async function reviewMovie(movie: string): Promise<MovieReview> {
const response = await openai.chat.completions.create({
model: "gpt-4o",
messages: [
{
role: "system",
content: "You are a film critic. Return a JSON review.",
},
{
role: "user",
content: `Review the movie "${movie}". Return JSON with fields: title (string), rating (number 1-10), summary (string), tags (string array).`,
},
],
});
const content = response.choices[0].message.content;
// This is where things go wrong
return JSON.parse(content) as MovieReview;
}
The as MovieReview cast is a lie. It tells TypeScript to trust you, but nothing actually verifies the shape of the data. The model might wrap the JSON in markdown fences, add a preamble like "Here's your review:", use a string for the rating instead of a number, or omit the tags array entirely. Any of these will cause runtime errors downstream.
Solution 1: Runtime Validation with Zod
Zod is the most popular runtime validation library in the TypeScript ecosystem. It lets you define schemas that serve double duty: they generate TypeScript types for compile-time safety and validate data at runtime. This makes Zod an ideal companion for LLM output enforcement.
Defining a Schema
import { z } from "zod";
const MovieReviewSchema = z.object({
title: z.string().min(1),
rating: z.number().min(1).max(10),
summary: z.string().min(10).max(500),
tags: z.array(z.string()).min(1).max(10),
releaseYear: z.number().int().optional(),
});
// This type is inferred from the schema
type MovieReview = z.infer<typeof MovieReviewSchema>;
Now you have a single source of truth. The schema defines the shape, and z.infer gives you the TypeScript type for free. No more maintaining separate interfaces and validators.
Validating LLM Output
import OpenAI from "openai";
import { z } from "zod";
const openai = new OpenAI();
const MovieReviewSchema = z.object({
title: z.string().min(1),
rating: z.number().min(1).max(10),
summary: z.string(),
tags: z.array(z.string()),
});
async function reviewMovie(movie: string): Promise<MovieReview> {
const response = await openai.chat.completions.create({
model: "gpt-4o",
messages: [
{
role: "system",
content: `You are a film critic. Respond ONLY with valid JSON matching this schema:
{
"title": string,
"rating": number (1-10),
"summary": string,
"tags": string[]
}
Do not include any text before or after the JSON.`,
},
{ role: "user", content: `Review "${movie}".` },
],
});
const content = response.choices[0].message.content ?? "";
// Strip markdown code fences if present
const cleaned = content
.replace(/json\n?/g, "")
.replace(/\n?/g, "")
.trim();
const parsed = JSON.parse(cleaned);
const result = MovieReviewSchema.safeParse(parsed);
if (!result.success) {
console.error("Validation failed:", result.error.format());
throw new Error(`LLM output did not match schema: ${result.error.message}`);
}
return result.data;
}
The safeParse method is key here. Unlike parse, which throws on failure, safeParse returns a result object with a success boolean. This lets you handle validation failures gracefully, log detailed errors, or trigger a retry.
Solution 2: Structured Outputs API
Modern LLM providers have recognized this problem and built structured output features directly into their APIs. OpenAI's Structured Outputs, for example, lets you pass a JSON schema and the model is constrained to produce output that matches it. This is fundamentally different from prompt engineering—the model is decoding tokens under schema constraints.
Using OpenAI Structured Outputs with Zod
import OpenAI from "openai";
import { z } from "zod";
const openai = new OpenAI();
const MovieReviewSchema = z.object({
title: z.string(),
rating: z.number().min(1).max(10),
summary: z.string(),
tags: z.array(z.string()),
recommended: z.boolean(),
});
type MovieReview = z.infer<typeof MovieReviewSchema>;
async function reviewMovie(movie: string): Promise<MovieReview> {
const response = await openai.beta.chat.completions.parse({
model: "gpt-4o",
messages: [
{
role: "system",
content: "You are a film critic. Provide a structured review.",
},
{ role: "user", content: `Review "${movie}".` },
],
response_format: zodResponseFormat(MovieReviewSchema, "movie_review"),
});
const review = response.choices[0].message.parsed;
if (!review) {
throw new Error("Failed to parse structured output");
}
return review;
}
OpenAI provides a zodResponseFormat helper that converts your Zod schema into the JSON schema format their API expects. The model is then constrained during generation, dramatically reducing the chance of malformed output. The parsed field on the response is already typed as MovieReview, giving you end-to-end type safety.
Using the Vercel AI SDK
The Vercel AI SDK provides a model-agnostic abstraction for structured outputs, which is valuable if you want to switch between providers or support multiple models:
import { generateObject } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";
const MovieReviewSchema = z.object({
title: z.string().describe("The title of the movie"),
rating: z.number().min(1).max(10).describe("Rating from 1 to 10"),
summary: z.string().describe("A brief summary of the review"),
tags: z.array(z.string()).describe("Genre and theme tags"),
sentiment: z.enum(["positive", "neutral", "negative"]),
});
const result = await generateObject({
model: openai("gpt-4o"),
schema: MovieReviewSchema,
prompt: "Write a review of The Matrix (1999).",
});
console.log(result.object.title); // "The Matrix"
console.log(result.object.rating); // 9
console.log(result.object.sentiment); // "positive"
The generateObject function handles the structured output API under the hood, validates the response against your Zod schema, and returns a fully typed object. The .describe() calls on schema fields are passed to the model as field descriptions, which improves output quality.
Solution 3: Using Instructor for Automatic Retries
Instructor is a library built specifically for structured LLM outputs. Its standout feature is automatic retry logic: when validation fails, it sends the validation errors back to the model and asks it to fix the output. This self-correcting loop can dramatically improve reliability.
import Instructor from "@instructor-ai/instructor";
import { z } from "zod";
import OpenAI from "openai";
const client = Instructor({
client: new OpenAI(),
mode: "TOOLS",
});
const MovieReviewSchema = z.object({
title: z.string(),
rating: z.number().min(1).max(10),
summary: z.string().min(20),
tags: z.array(z.string()).min(1),
criticName: z.string(),
});
type MovieReview = z.infer<typeof MovieReviewSchema>;
const review = await client.chat.completions.create({
model: "gpt-4o",
messages: [
{
role: "user",
content: "Review the movie 'Blade Runner 2049'. Be thorough.",
},
],
response_model: {
schema: MovieReviewSchema,
name: "MovieReview",
},
max_retries: 3,
});
// review is fully typed as MovieReview
console.log(review.title);
console.log(review.rating);
If the model returns a rating of 15 (outside the 1-10 range), Instructor catches the Zod validation error, sends the error message back to the model, and asks it to correct its output. This happens up to max_retries times before throwing.
Handling Complex and Nested Schemas
Real-world applications rarely need flat objects. You'll often have nested structures, arrays of objects, enums, and optional fields. Zod handles all of these elegantly:
import { z } from "zod";
const AddressSchema = z.object({
street: z.string(),
city: z.string(),
state: z.string(),
zipCode: z.string().regex(/^\d{5}(-\d{4})?$/),
});
const OrderItemSchema = z.object({
productName: z.string(),
quantity: z.number().int().positive(),
unitPrice: z.number().nonnegative(),
category: z.enum(["electronics", "clothing", "food", "other"]),
});
const CustomerOrderSchema = z.object({
orderId: z.string().uuid(),
customerName: z.string().min(1),
email: z.string().email(),
shippingAddress: AddressSchema,
billingAddress: AddressSchema.optional(),
items: z.array(OrderItemSchema).min(1),
orderDate: z.string().datetime(),
status: z.enum(["pending", "processing", "shipped", "delivered", "cancelled"]),
notes: z.string().nullable(),
});
type CustomerOrder = z.infer<typeof CustomerOrderSchema>;
When passing complex schemas to LLMs, the model needs to understand the full structure. Using structured output APIs is especially important here because prompt-based JSON generation struggles with deeply nested schemas. The .describe() method on each field helps the model understand what each field should contain.
Best Practices
Always Validate, Even with Structured Outputs
Even when using provider-side structured outputs, run the result through Zod validation. APIs can change, models can hallucinate within constraints, and edge cases exist. Defense in depth is worth the minimal performance cost.
Use Descriptive Schema Names and Field Descriptions
const ProductSchema = z.object({
name: z.string().describe("The full product name including brand"),
price: z.number().describe("Price in USD, e.g., 29.99"),
inStock: z.boolean().describe("Whether the product is currently available"),
sku: z.string().describe("Stock keeping unit, alphanumeric, 8-12 characters"),
});
Descriptions are passed to the model and significantly improve output quality, especially for ambiguous fields.
Keep Schemas Focused
Don't ask the LLM to return 50 fields in one call if you only need 10. Larger schemas increase the chance of errors and increase token costs. Break complex tasks into multiple smaller, focused calls if needed.
Implement Retry Logic
Whether you use Instructor's built-in retries or roll your own, always have a retry strategy. A simple pattern is to catch validation errors, append them to the conversation, and ask the model to correct its output:
import { z } from "zod";
const Schema = z.object({
name: z.string(),
age: z.number().int().min(0).max(150),
});
async function extractWithRetry(
prompt: string,
maxAttempts = 3
) {
const messages = [
{ role: "system", content: "Extract data and return valid JSON only." },
{ role: "user", content: prompt },
];
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const response = await callLLM(messages);
const result = Schema.safeParse(JSON.parse(response));
if (result.success) {
return result.data;
}
// Feed the error back to the model
messages.push({ role: "assistant", content: response });
messages.push({
role: "user",
content: `The previous response failed validation:
${JSON.stringify(result.error.format(), null, 2)}
Please fix the errors and return valid JSON.`,
});
}
throw new Error("Max retry attempts reached");
}
Use Enums for Constrained Values
When a field can only take specific values, use z.enum(). This prevents the model from inventing new categories and makes your downstream logic predictable:
const SentimentSchema = z.enum(["positive", "negative", "neutral"]);
const PrioritySchema = z.enum(["low", "medium", "high", "critical"]);
Log Validation Failures
Validation failures are valuable signal. Log the raw model output alongside the validation errors so you can identify patterns—maybe the model consistently struggles with a particular field, which means you need to improve your prompt or schema description.
Conclusion
Enforcing TypeScript types on LLM outputs transforms AI from an unpredictable text generator into a reliable component in your software pipeline. By combining Zod schemas for runtime validation with structured output APIs from providers like OpenAI or the Vercel AI SDK, you get compile-time type safety, runtime guarantees, and self-correcting retry loops. The key principles are simple: define your contracts with Zod, use provider-side structured outputs when available, always validate at runtime, and implement retry logic for when things go wrong. With these patterns in place, you can build production applications that consume LLM outputs with the same confidence you'd have calling any other typed API.