Store Chat Messages & State Without Managing Infrastructure.Check Out DialogueDB
Skip to content
Back to examples

A Type-Safe LLM Function

Raw LLM SDK calls give you a string and a shrug: JSON.parse the response, cast it to any, and hope. This recipe builds an LLM call that behaves like a regular typed function — you define the output shape once as a schema, and TypeScript infers the return type from it. If the model returns something that doesn't match, the call throws instead of letting bad data flow downstream.

Step 1 - Imports

ts
import {
  useLlm,
  createChatPrompt,
  createParser,
  createLlmExecutor,
  defineSchema,
} from "llm-exe";

Step 2 - Define the Output Schema

defineSchema does double duty: at runtime it validates the model's response, and at compile time it becomes the function's return type via inference. The description fields aren't decoration — they're rendered into the prompt so the model knows what each field means.

ts
export const reviewSchema = defineSchema({
  type: "object",
  properties: {
    sentiment: {
      type: "string",
      enum: ["positive", "neutral", "negative"],
      description: "The overall sentiment of the review.",
    },
    summary: {
      type: "string",
      description: "A one-sentence summary of the review.",
    },
    actionNeeded: {
      type: "boolean",
      description: "Does this review require a follow-up from support?",
    },
  },
  required: ["sentiment", "summary", "actionNeeded"],
  additionalProperties: false,
});

Step 3 - Prepare the Prompt

The JsonSchema partial (the >JsonSchema key='schema' line in the template below) renders the schema into the prompt as formatting instructions, so the prompt and the validator can never drift apart — they're the same object.

ts
export const PROMPT = `Analyze the customer product review below.

Respond with only valid JSON. Your response must EXACTLY follow this JSON Schema:

{{>JsonSchema key='schema'}}

Review:
{{review}}`;

Step 4 - Create the Function

ts
export async function analyzeReview(review: string) {
  const llm = useLlm("openai.gpt-4o-mini");
  const prompt = createChatPrompt<{
    review: string;
    schema: typeof reviewSchema;
  }>(PROMPT);
  const parser = createParser("json", { schema: reviewSchema });

  return createLlmExecutor({
    name: "analyze-review",
    llm,
    prompt,
    parser,
  }).execute({ review, schema: reviewSchema });
}

Step 5 - Use it!

typescript
const analysis = await analyzeReview(
  "Arrived two weeks late and the box was crushed. Product works though."
);

// All of this is inferred — no casts, no `any`:
analysis.sentiment;    // "positive" | "neutral" | "negative"
analysis.summary;      // string
analysis.actionNeeded; // boolean

if (analysis.actionNeeded) {
  await createSupportTicket(analysis.summary);
}

Rename actionNeeded in the schema and every usage becomes a compile error. That's the point: changes to the LLM contract surface in tsc, not in production.

Why this beats parse-and-cast

  • One source of truth. The schema is the prompt instructions, the runtime validator, and the TypeScript type. There is no second place to update.
  • Failures are loud. A malformed or off-schema response throws an LlmExeError — it can't silently become undefined three modules later.
  • The function is testable. It's just an async function with a typed input and output; mock it like anything else.