IF.
Notes
typescriptrustllmzodserdeai-engineering

TypeScript vs Rust for LLM JSON Validation: A Production Comparison

5 min read

Every LLM integration has a dirty secret: the model doesn't return data. It returns text that looks like data. The distance between those two things is where production incidents live.

After shipping LLM pipelines in both TypeScript and Rust, I want to be direct about the tradeoffs. This isn't a language war. It's an engineering comparison with a specific constraint: the input is probabilistic, the downstream is deterministic, and a mismatch is a bug.


The Problem Space

Consider a simple prompt asking an LLM to extract structured information from a support ticket:

Extract the following fields from the ticket:
- priority: "low" | "medium" | "high" | "critical"  
- category: string
- estimated_hours: number (integer, 1–40)
- tags: string[]
 
Return valid JSON only.

Even with response_format: { type: "json_object" }, the model will, at some frequency:

  • Return "estimated_hours": "8" instead of 8
  • Omit optional fields entirely
  • Return "priority": "urgent" instead of "critical"
  • Nest objects unexpectedly
  • Return null for fields it couldn't infer

Your validator catches all of this — or it doesn't, and you find out in a Sentry alert at 2am.


The TypeScript Approach: Zod

Zod is the de-facto standard for runtime validation in TypeScript. Its API is deliberately schema-first, which aligns well with the contract-driven nature of LLM output.

Defining the schema

import { z } from "zod";
 
const TicketExtraction = z.object({
  priority: z.enum(["low", "medium", "high", "critical"]),
  category: z.string().min(1).max(128),
  estimated_hours: z.number().int().min(1).max(40),
  tags: z.array(z.string()).default([]),
});
 
type TicketExtraction = z.infer<typeof TicketExtraction>;

Parsing with coercion

Raw LLM output is a string. You parse it, then validate it:

async function extractTicketData(
  rawLLMResponse: string
): Promise<TicketExtraction> {
  let parsed: unknown;
 
  try {
    parsed = JSON.parse(rawLLMResponse);
  } catch (e) {
    throw new Error(`LLM returned non-JSON: ${rawLLMResponse.slice(0, 100)}`);
  }
 
  const result = TicketExtraction.safeParse(parsed);
 
  if (!result.success) {
    // Zod gives you a structured error tree
    const issues = result.error.issues
      .map((i) => `[${i.path.join(".")}]: ${i.message}`)
      .join("\n");
    throw new Error(`Validation failed:\n${issues}`);
  }
 
  return result.data;
}

The coercion escape hatch

LLMs frequently stringify numbers. Zod handles this gracefully:

const Lenient = z.object({
  // Accept "8" or 8, coerce to number
  estimated_hours: z.coerce.number().int().min(1).max(40),
  // Accept "true", true, 1 — coerce to boolean
  is_billable: z.coerce.boolean().optional(),
});

z.coerce.* uses JavaScript's native coercion rules. It's pragmatic. It's also a footgun if you're not deliberate about where you apply it — don't reach for it globally.


The Rust Approach: Serde

Serde is not a validation library. It's a serialisation framework. This distinction matters. Where Zod combines schema definition and validation in one step, Serde handles deserialisation and you layer validation on top — typically via the validator crate or custom Deserialize implementations.

Defining the struct

use serde::{Deserialize, Serialize};
 
#[derive(Debug, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum Priority {
    Low,
    Medium,
    High,
    Critical,
}
 
#[derive(Debug, Deserialize, Serialize)]
pub struct TicketExtraction {
    pub priority: Priority,
    pub category: String,
    pub estimated_hours: u8,
    #[serde(default)]
    pub tags: Vec<String>,
}

Parsing from raw string

use anyhow::{Context, Result};
use serde_json;
 
fn extract_ticket_data(raw_response: &str) -> Result<TicketExtraction> {
    let extraction: TicketExtraction = serde_json::from_str(raw_response)
        .with_context(|| format!(
            "Failed to deserialise LLM response. First 100 chars: {}",
            &raw_response[..raw_response.len().min(100)]
        ))?;
 
    Ok(extraction)
}

That's it for the happy path. Serde's derive macros handle the mapping, and the Priority enum means any string that isn't a valid variant causes an immediate Err.

The validation gap

Serde will happily deserialise estimated_hours: 255 — it's a valid u8. For domain constraints (1–40), you need to add validation:

use validator::Validate;
 
#[derive(Debug, Deserialize, Serialize, Validate)]
pub struct TicketExtraction {
    pub priority: Priority,
    #[validate(length(min = 1, max = 128))]
    pub category: String,
    #[validate(range(min = 1, max = 40))]
    pub estimated_hours: u8,
    #[serde(default)]
    pub tags: Vec<String>,
}
 
fn extract_ticket_data(raw_response: &str) -> Result<TicketExtraction> {
    let extraction: TicketExtraction = serde_json::from_str(raw_response)
        .context("LLM response is not valid JSON for TicketExtraction")?;
 
    extraction.validate()
        .context("LLM response failed domain validation")?;
 
    Ok(extraction)
}

Key Tradeoffs at a Glance

ConcernTypeScript + ZodRust + Serde
Schema → type inferenceNative (z.infer<>)Struct is the source of truth
Parse + validate in one stepYesNo — two steps (serde + validator)
Coercion for messy LLM outputFirst-class (z.coerce)Manual #[serde(deserialize_with)]
Error messagesStructured, path-awareContext-chain via anyhow
Compile-time exhaustiveness❌ (runtime only)✅ (enum variant matching)
PerformanceAdequate10–100× faster
Ecosystem for LLM SDKsRich (OpenAI, Anthropic, Vercel AI)Thin (async-openai, manual)

Where Rust Wins Unambiguously

The Rust enum variant system is genuinely superior for closed-domain string fields. Consider:

#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ModelProvider {
    OpenAi,
    Anthropic,
    Gemini,
    Local,
}

At compile time, every match on ModelProvider is exhaustive. Add a variant, and the compiler tells you every call site that needs updating. In TypeScript, an as const union gets you close, but the exhaustiveness is enforced only at the type level — not at runtime parsing.


Where TypeScript + Zod Wins

Iteration speed and ecosystem depth. When you're prompting and validating in a tight feedback loop — testing different output schemas, tweaking field names, adding optional fields — Zod's DX is genuinely faster. Schema changes don't require a recompile. Error messages are immediately human-readable. And the entire Vercel AI SDK / LangChain ecosystem speaks Zod natively.

// Structured outputs via the OpenAI SDK — Zod schema used directly
const response = await openai.beta.chat.completions.parse({
  model: "gpt-4o",
  messages: [{ role: "user", content: prompt }],
  response_format: zodResponseFormat(TicketExtraction, "ticket_extraction"),
});
 
const result = response.choices[0].message.parsed;
//     ^? TicketExtraction — fully typed, already validated

That integration is genuinely ergonomic. The schema you defined for validation becomes your contract with the model API.


My Default Position

Start with TypeScript + Zod. The ecosystem advantage is decisive during the experimentation phase, and most LLM pipelines live longest in that phase.

Migrate the hot path to Rust when you have stable schemas, high throughput requirements, and the latency budget for JSON parsing is actually measurable. That moment arrives less often than Rust advocates imply.

The real lesson isn't about language choice. It's that every boundary between a language model and your application code is a validation surface. Treat it with the same rigour you would a public API. The model is not your colleague — it's an external system that occasionally returns garbage.

Schema it. Parse it. Test it with adversarial inputs. Then forget the language.