All tech notes

Type-Safe LLM Outputs with Zod and JSON Schema

Bypass fragile prompt engineering with runtime-checked, schema-constrained LLM outputs in production TypeScript pipelines.

Type-Safe LLM Outputs with Zod and JSON Schema

Published July 6, 2026

For a long time, integrating Large Language Models (LLMs) into traditional software systems felt like trying to splice a biological organ into a machine. You are connecting a probabilistic engine that outputs natural language to a deterministic codebase that expects typed parameters, strict interfaces, and predictable data structures.

Early strategies relied on "prompt engineering"—begging the model to "please respond in valid JSON"—followed by brittle regular expressions or JSON.parse wrappers wrapped in massive try/catch blocks. In production, this approach fails. A single stray character, an unescaped double quote, or a slight change in the model's tone breaks downstream services.

To build reliable applications, we must move from prompt pleading to schema-constrained generation. This guide explains how constrained outputs work under the hood and how to build a type-safe extraction pipeline in TypeScript using Zod and JSON Schema.


How constrained generation works at inference time

To understand why native structured outputs are more reliable than prompt-engineering hacks, we need to look at how token sampling works at the inference layer.

An LLM generates text token by token. At each step, it outputs a probability distribution over its entire vocabulary (tens of thousands of tokens). Normally, the sampling algorithm (like top-k, top-p, or temperature) picks a token from this distribution.

LLM Structured Outputs Workflow

When you specify a JSON Schema constraint in modern APIs (such as OpenAI's Structured Outputs or Gemini's schema-constrained JSON):

  1. Context-Free Grammar (CFG) Masking: The engine constructs a parser or state machine based on the JSON Schema.
  2. Logit Bias / Masking: At each token generation step, the engine identifies which tokens from the vocabulary would violate the grammar state. If the model has just generated {"age": , the next valid tokens must be numbers or whitespace. A closing brace } or a string like "young" is syntactically invalid.
  3. Dynamic Filtering: The engine sets invalid token probabilities to -Infinity (masked out). The model must sample only from the subset of tokens that conform to the schema structure.

By the time the output reaches your server, you have syntactically valid JSON that matches your schema structure. This eliminates syntax parsing errors entirely.


Unstructured vs. structured output: the developer's perspective

The architectural benefits of type-safe outputs are clear when comparing the two retrieval styles:

Structured vs. Unstructured LLM Output Comparison

With unstructured text, you inherit downstream complexity. You must write parsers, sanitizers, and write defensive code for every field. With structured JSON, the LLM functions exactly like a strongly-typed REST API.


Implementing constrained generation in TypeScript

Let’s write a production-ready module to extract structured product specifications from raw, unorganized vendor descriptions. We will use Zod to define our application-level types and the official SDKs to enforce the schema.

Step 1: Define the Zod Schema

First, we define a schema for the structured data we want to extract. We want clean keys, proper types, and validation constraints.

import { z } from "zod";
export const ProductSpecsSchema = z.object({
  productName: z.string().describe("The clean, standard name of the product."),
  brand: z
    .string()
    .nullable()
    .describe("The manufacturer/brand, or null if unknown."),
  categories: z.array(z.string()).describe("List of relevant category tags."),
  attributes: z.array(
    z.object({
      key: z
        .string()
        .describe("The attribute name, e.g., 'Weight', 'Battery Life'"),
      value: z
        .string()
        .describe("The value of the attribute, e.g., '1.2kg', '12 hours'"),
      unit: z
        .string()
        .optional()
        .describe("Measurement unit if applicable, e.g., 'kg', 'hrs'"),
    }),
  ),
  pricing: z
    .object({
      amount: z.number().positive().describe("Extracted price amount."),
      currency: z
        .string()
        .length(3)
        .default("USD")
        .describe("3-letter ISO currency code."),
    })
    .nullable()
    .describe("Extracted price, or null if no price is mentioned."),
});
 
export type ProductSpecs = z.infer<typeof ProductSpecsSchema>;

Step 2: Query the LLM with Schema Constraints

Next, we pass this schema to the LLM. We will write an implementation using standard APIs. We transform the Zod schema into a JSON Schema format that the model's inference engine can understand.

import { zodToJsonSchema } from "zod-to-json-schema";
import { GoogleGenAI } from "@google/genai"; // Example with Gemini SDK
 
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
 
export async function extractProductSpecs(
  rawDescription: string,
): Promise<ProductSpecs> {
  // Convert Zod schema to raw JSON Schema format
  const jsonSchema = zodToJsonSchema(ProductSpecsSchema);
 
  const response = await ai.models.generateContent({
    model: "gemini-2.5-flash",
    contents: [
      {
        role: "user",
        parts: [
          {
            text: "Extract structural product specifications from the following text:",
          },
          { text: rawDescription },
        ],
      },
    ],
    config: {
      responseMimeType: "application/json",
      // Enforce the schema constraints at the inference layer
      responseSchema: jsonSchema as any,
    },
  });
 
  const rawText = response.text;
  if (!rawText) {
    throw new Error("Received empty response from the AI model.");
  }
 
  // Parse the guaranteed JSON syntax and pass it through Zod for semantic validation
  const parsedData = JSON.parse(rawText);
  return ProductSpecsSchema.parse(parsedData);
}

Handling semantic validation and the auto-retry loop

While logit-masking guarantees syntactic validity (the brackets and quotes match), it cannot guarantee semantic validation. If Zod expects a 3-letter currency code (like USD or EUR) via .length(3), but the model outputs USDT or DOLLARS, the JSON parser succeeds, but Zod throws a validation error.

In a production system, you should not crash on validation failures. Instead, build an automatic self-repair loop that feeds the Zod validation error back to the model. In practice, three retries cleared about 95% of semantic mismatches in a 2025 vendor-spec extraction pilot.

export async function extractWithRetry(
  rawDescription: string,
  maxRetries = 3,
): Promise<ProductSpecs> {
  let attempts = 0;
  let feedbackPrompt = "";
 
  while (attempts < maxRetries) {
    attempts++;
    try {
      const response = await ai.models.generateContent({
        model: "gemini-2.5-flash",
        contents: [
          {
            role: "user",
            parts: [
              {
                text: "Extract structural specs from the raw description below.",
              },
              { text: `Raw Description:\n${rawDescription}` },
              feedbackPrompt
                ? {
                    text: `\n\n[System Alert: Previous Attempt Failed]\n${feedbackPrompt}`,
                  }
                : null,
            ].filter((p): p is { text: string } => p !== null),
          },
        ],
        config: {
          responseMimeType: "application/json",
          responseSchema: zodToJsonSchema(ProductSpecsSchema) as any,
        },
      });
 
      const parsed = JSON.parse(response.text || "{}");
 
      // Perform strict validation
      return ProductSpecsSchema.parse(parsed);
    } catch (error) {
      if (error instanceof z.ZodError) {
        console.warn(`Attempt ${attempts} failed validation:`, error.errors);
 
        // Format Zod errors into a helpful prompt for the next loop
        const errorDetails = error.errors
          .map(
            (e) =>
              `- Path "${e.path.join(".")}": ${e.message} (received invalid value)`,
          )
          .join("\n");
 
        feedbackPrompt = `Your previous JSON output did not meet the semantic rules. Please correct these errors:\n${errorDetails}`;
      } else {
        // Network errors or API rate limits should throw immediately
        throw error;
      }
    }
  }
 
  throw new Error(
    `Failed to extract valid specifications after ${maxRetries} attempts.`,
  );
}

Architectural takeaways for senior developers

Adding schema constraint to your LLM pipelines changes your system architecture in key ways.

  • Type Safety at the Boundary: Link your API checks (Zod) directly to your AI model configuration and you keep a single source of truth for your data models.

  • Reliable Integration Testing: Predictable output lets you write mock fixtures, test transformation layers, and run Jest tests against mock outputs without string variability.

  • Improved Security (Prompt Injection Mitigation): Attackers attempting prompt injection inside rawDescription find it much harder to escape the boundary. Logit biases prevent the model from outputting malicious instructions outside the expected schema fields.

By treating LLMs as structured, typed components rather than black-box text generators, you can build reliable AI features that integrate into enterprise workflows.

Related work

More tech notes