Skip to main content
Lesson 9 of 12 · Structured Output: JSON and Schemas

Zod Schemas in TypeScript

Lesson 9 of 12 Updated November 3, 2026 Developer
On this page
  1. Zod: The TypeScript Equivalent of Pydantic
  2. Defining a Simple Schema
  3. Types You Will Need
  4. Nested Schemas
  5. A Complete Call with the Mistral SDK
  6. Describing Fields with .describe()
  7. Error Handling
  8. Key Takeaways

Updated November 3, 2026 after compiling with @mistralai/mistralai SDK 3.5.0, Zod 4.6, and TypeScript 7.0.2. The original example imported Mistral by default, but the SDK only offers a named export. It also read a response variable in main() that only existed inside extractBook(). Accessing parsed ignored that choices and message are optional in the SDK types, and it treated parsed as a Book when its type is actually unknown. The ZodError import was unnecessary: chat.parse() validates with safeParse and never throws. The API key is no longer hardcoded. Sources: Mistral TypeScript SDK · official chat.parse example.

Zod: The TypeScript Equivalent of Pydantic

If you work in TypeScript or JavaScript, Zod is the schema validation library used by the Mistral SDK for Custom Structured Outputs. It plays exactly the same role as Pydantic in Python: defining a strict schema that the model must follow. Everything you learned about schemas applies here, with only syntax differences.

Install both the SDK and Zod in a single command:

npm install @mistralai/mistralai zod

Zod works natively with TypeScript and provides automatic type inference—its main advantage over writing interfaces by hand.

Defining a Simple Schema

Build a Zod schema with z.object(), assigning a validator to each field. The z.infer line makes the difference: it deduces the TypeScript type from the schema, so you don’t have to maintain two parallel declarations that could drift apart.

import { z } from "zod";

const ContactSchema = z.object({
  name: z.string(),
  email: z.string(),
  phone: z.string(),
  company: z.string(),
});

// TypeScript automatically infers the type
type Contact = z.infer<typeof ContactSchema>;
// => { name: string; email: string; phone: string; company: string }

Types You Will Need

Zod covers every need for structured outputs. The ticket schema below brings together common cases: basic fields, a nullable field for an unassigned ticket, an enum for priority, and typed arrays.

import { z } from "zod";

const TicketSchema = z.object({
  // Basic types
  title: z.string(),
  description: z.string(),
  ticketId: z.number(),
  confidence: z.number(),
  isUrgent: z.boolean(),

  // Optional (can be null)
  assignee: z.string().nullable(),

  // Enum (constrained values)
  priority: z.enum(["low", "medium", "high", "critical"]),

  // Lists
  tags: z.array(z.string()),
  relatedIds: z.array(z.number()),
});

type Ticket = z.infer<typeof TicketSchema>;

One nuance deserves attention: where Python distinguishes int and float, Zod knows only z.number(). Keep this mapping in mind when porting a schema from one language to the other.

Pydantic (Python)Zod (TypeScript)
strz.string()
int, floatz.number()
boolz.boolean()
list[str]z.array(z.string())
Optional[str]z.string().nullable()
Enumz.enum([...])

Nested Schemas

As with Pydantic, a schema can contain another: just reference the constant. Composition then reads from the innermost to the outermost: the address inside the company, the company inside the person.

import { z } from "zod";

const AddressSchema = z.object({
  street: z.string(),
  city: z.string(),
  postalCode: z.string(),
  country: z.string(),
});

const CompanySchema = z.object({
  name: z.string(),
  industry: z.string(),
  address: AddressSchema,
});

const PersonSchema = z.object({
  firstName: z.string(),
  lastName: z.string(),
  email: z.string(),
  role: z.string(),
  company: CompanySchema,
});

type Person = z.infer<typeof PersonSchema>;

A Complete Call with the Mistral SDK

Here is the same book extraction from the Python lesson, ported to TypeScript. Three points need attention. The parameter is responseFormat in camelCase. You must test parsed before using it: the SDK validates the response against the schema with safeParse and, if the JSON is truncated or does not match the schema, leaves parsed as undefined instead of throwing an error. Finally, the SDK does not carry your schema’s type: it declares parsed as unknown. Passing the object through BookSchema.parse() makes it typed as Book and revalidates it in the process.

import { Mistral } from "@mistralai/mistralai";
import { z } from "zod";

// 1. Define the schema
const BookSchema = z.object({
  title: z.string().describe("Book title"),
  authors: z.array(z.string()).describe("List of authors"),
  year: z.number().nullable().describe("Publication year"),
  genre: z.string().describe("Main literary genre"),
  summary: z.string().describe("Summary in 2-3 sentences"),
});

type Book = z.infer<typeof BookSchema>;

// 2. Create the client
const client = new Mistral({ apiKey: process.env.MISTRAL_API_KEY });

// 3. Call chat.parse()
async function extractBook(text: string): Promise<Book> {
  const response = await client.chat.parse({
    model: "mistral-large-latest",
    messages: [
      {
        role: "system",
        content: "You are an expert librarian. Extract book information.",
      },
      { role: "user", content: text },
    ],
    responseFormat: BookSchema,
    temperature: 0,
  });

  // 4. Access the parsed object
  const message = response.choices?.[0]?.message;
  if (!message?.parsed) {
    throw new Error("Response missing, truncated, or non-compliant with schema");
  }
  // Raw JSON remains available in message.content
  console.log(`Raw JSON: ${message.content}`);
  // parsed is typed as unknown: BookSchema.parse() returns a typed Book
  return BookSchema.parse(message.parsed);
}

// 5. Usage
async function main() {
  const book = await extractBook(
    "I loved '1984' by George Orwell, published in 1949. A dystopian classic."
  );

  console.log(`Title: ${book.title}`);
  console.log(`Authors: ${book.authors.join(", ")}`);
  console.log(`Year: ${book.year}`);
  console.log(`Genre: ${book.genre}`);
}

main();

Describing Fields with .describe()

In Zod, .describe() fills the same role as Field(description=...) in Pydantic: telling the model what the field should contain, especially when the name alone is ambiguous. A score without a description might land on a 0–100 scale; with an explicit range mentioned, it follows your convention.

const SentimentSchema = z.object({
  sentiment: z.enum(["positive", "negative", "neutral"]).describe(
    "Overall sentiment of the text"
  ),
  score: z.number().describe(
    "Confidence score between 0.0 and 1.0"
  ),
  keywords: z.array(z.string()).describe(
    "The 3–5 main keywords"
  ),
  summary: z.string().describe(
    "Summary in one sentence maximum"
  ),
});

Error Handling

The retry pattern follows the same logic as in Python, with a generic signature that makes it reusable for any schema. There is no need to catch a ZodError: chat.parse() does not throw one; it leaves parsed empty. That case, along with network or HTTP errors, triggers a retry. The constraint z.ZodType<T> links the schema passed as an argument to the return type: schema.parse() returns a T, so the caller gets a properly typed object without extra annotations.

// Continuation of the same file: client, z, and BookSchema are already defined

async function safeExtract<T>(
  text: string,
  schema: z.ZodType<T>,
  maxRetries = 2
): Promise<T> {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const response = await client.chat.parse({
        model: "mistral-large-latest",
        messages: [
          { role: "system", content: "Extract the requested information." },
          { role: "user", content: text },
        ],
        responseFormat: schema,
        temperature: 0,
      });

      const parsed = response.choices?.[0]?.message?.parsed;
      if (!parsed) {
        throw new Error("Response missing or non-compliant with schema");
      }
      return schema.parse(parsed); // restores type T
    } catch (error) {
      console.error(`Attempt ${attempt + 1} failed:`, error);
      if (attempt === maxRetries) throw error;
    }
  }
  throw new Error("Unexpected failure");
}

// Usage
try {
  const book = await safeExtract("My text here", BookSchema);
  console.log(book.title);
} catch (error) {
  console.error("Extraction failed:", error);
}

Key Takeaways

  • Zod is the TypeScript equivalent of Pydantic for Custom Structured Outputs
  • z.infer<typeof Schema> automatically generates the TypeScript type
  • .describe() adds per-field instructions, like Field(description=...) in Python
  • The Mistral TypeScript SDK uses responseFormat (camelCase) instead of response_format
  • A non-compliant response leaves parsed as undefined (no exception): test for it, then retry if needed