Examples

Before/after comparisons showing what changes when you use llm-contract.

Sentiment Analysis Invoice Extraction Ticket Classification

Sentiment Analysis

Parse LLM output into a typed sentiment + confidence score.

Before ~50 lines, fragile
async function analyzeSentiment(text: string) {
  const response = await openai.chat.completions.create({
    model: "gpt-4o-mini",
    messages: [{
      role: "system",
      content: "Analyze the sentiment. Return JSON with: "
        + "sentiment (positive/negative/neutral), "
        + "confidence (0-1). Return ONLY JSON.",
    }, { role: "user", content: text }],
  });

  const raw = response.choices[0].message.content ?? "";

  let cleaned = raw
    .replace(/```json\n?/g, "")
    .replace(/```\n?/g, "")
    .trim();

  let parsed;
  try {
    parsed = JSON.parse(cleaned);
  } catch {
    throw new Error(`Invalid JSON: ${raw}`);
  }

  if (!["positive", "negative", "neutral"]
    .includes(parsed.sentiment)) {
    throw new Error(`Bad sentiment: ${parsed.sentiment}`);
  }
  if (typeof parsed.confidence !== "number"
    || parsed.confidence < 0
    || parsed.confidence > 1) {
    throw new Error(`Bad confidence: ${parsed.confidence}`);
  }

  return parsed as {
    sentiment: "positive" | "negative" | "neutral";
    confidence: number;
  };
}
After ~15 lines, typed
import { enforce } from "llm-contract";
import { z } from "zod";

const Sentiment = z.object({
  sentiment: z.enum(["positive", "negative", "neutral"]),
  confidence: z.number().min(0).max(1),
});

async function analyzeSentiment(text: string) {
  return enforce(Sentiment, async (attempt) => {
    const res = await openai.chat.completions.create({
      model: "gpt-4o-mini",
      messages: [
        { role: "system", content: attempt.prompt },
        { role: "user", content: text },
        ...attempt.fixes,
      ],
    });
    return res.choices[0].message.content;
  });
}

What changed

Invoice Extraction

Extract structured data from PDFs with cross-field math invariants.

Before ~50 lines, no math checks
async function extractInvoice(pdfText: string) {
  let lastError: string | undefined;

  for (let attempt = 0; attempt < 3; attempt++) {
    const messages: Array<{ role: string; content: string }> = [
      {
        role: "system",
        content: `Extract these fields as JSON:
- vendor (string)
- invoiceNumber (string)
- date (YYYY-MM-DD)
- lineItems (array: description, quantity,
  unitPrice, amount)
- subtotal, tax, total (numbers)
Return ONLY valid JSON.`,
      },
      { role: "user", content: pdfText },
    ];

    if (attempt > 0 && lastError) {
      messages.push({
        role: "user",
        content: `Previous response was invalid:
  ${lastError}. Please fix.`,
      });
    }

    const res = await openai.chat.completions.create({
      model: "gpt-4o",
      messages: messages as any,
    });

    const raw = res.choices[0].message.content ?? "";
    let parsed: any;
    try {
      parsed = JSON.parse(
        raw.replace(/```json\n?/g, "")
           .replace(/```\n?/g, "").trim()
      );
    } catch {
      lastError = "Not valid JSON";
      continue;
    }

    if (typeof parsed.total !== "number") {
      lastError = "total must be a number";
      continue;
    }

    return parsed;
  }

  throw new Error(`Failed after 3 attempts: ${lastError}`);
}
After Schema + invariants
import { enforce } from "llm-contract";
import { z } from "zod";

const Invoice = z.object({
  vendor: z.string(),
  invoiceNumber: z.string(),
  date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
  lineItems: z.array(z.object({
    description: z.string(),
    quantity: z.number(),
    unitPrice: z.number(),
    amount: z.number(),
  })),
  subtotal: z.number(),
  tax: z.number(),
  total: z.number(),
});

async function extractInvoice(pdfText: string) {
  return enforce(Invoice, async (attempt) => {
    const res = await openai.chat.completions.create({
      model: "gpt-4o",
      messages: [
        { role: "system", content: attempt.prompt },
        { role: "user", content: pdfText },
        ...attempt.fixes,
      ],
    });
    return res.choices[0].message.content;
  }, {
    invariants: [
      (d) => {
        const sum = d.lineItems.reduce(
          (s, i) => s + i.amount, 0
        );
        return Math.abs(sum - d.subtotal) < 0.01
          || `line items sum to ${sum}, subtotal is ${d.subtotal}`;
      },
      (d) => Math.abs(d.subtotal + d.tax - d.total) < 0.01
        || `subtotal + tax != total`,
    ],
  });
}

What changed

Ticket Classification

Classify support tickets with constrained enums and bounded arrays.

Before Incomplete validation
async function classifyTicket(ticket: string) {
  const response = await openai.chat.completions.create({
    model: "gpt-4o-mini",
    messages: [{
      role: "system",
      content: `Classify this ticket as JSON:
- category: "bug", "feature", "question", "billing"
- priority: "low", "medium", "high", "critical"
- tags: array of relevant tags (max 5)
JSON only.`,
    }, { role: "user", content: ticket }],
  });

  const raw = response.choices[0].message.content ?? "";
  const cleaned = raw
    .replace(/```json\n?/g, "")
    .replace(/```/g, "").trim();
  const parsed = JSON.parse(cleaned);

  if (!["bug", "feature", "question", "billing"]
    .includes(parsed.category)) {
    throw new Error(`Invalid category: ${parsed.category}`);
  }

  // Priority validation missing
  // Tags length check missing

  return parsed;
}
After Complete validation
import { enforce } from "llm-contract";
import { z } from "zod";

const Ticket = z.object({
  category: z.enum([
    "bug", "feature", "question", "billing",
  ]),
  priority: z.enum([
    "low", "medium", "high", "critical",
  ]),
  tags: z.array(z.string()).max(5),
});

async function classifyTicket(ticket: string) {
  return enforce(Ticket, async (attempt) => {
    const res = await openai.chat.completions.create({
      model: "gpt-4o-mini",
      messages: [
        { role: "system", content: attempt.prompt },
        { role: "user", content: ticket },
        ...attempt.fixes,
      ],
    });
    return res.choices[0].message.content;
  });
}

What changed

The pattern

Every example follows the same shape:

const result = await enforce(schema, async (attempt) => {
  const { text } = await yourLLMCall({
    system: attempt.prompt,    // from schema
    messages: attempt.fixes,   // from previous failure
  });
  return text;
}, {
  invariants: [
    // cross-field rules Zod can't express
  ],
  onAttempt: (event) => {
    // observe every attempt — log, trace, alert
  },
});

Runnable examples

Full example files with simulated failures to show the contract loop in action:

extraction.ts

Invoice extraction with math invariants

classification.ts

Ticket classification with enums

moderation.ts

Content moderation with confidence gates

scoring.ts

Scoring with numeric constraints

fallback.ts

Graceful fallback on exhausted retries

primitives.ts

Using clean, check, fix, select individually