Prompt Engineering with Claude: Getting Reliable JSON Output in TypeScript

typescript dev.to

When LLMs return malformed JSON, your automation breaks. Learn how to coax Claude into producing strict, parse‑ready structures every time. We’ll walk through a real TypeScript example you can drop into production.


Why JSON Output Is Hard for LLMs

Large language models (LLMs) are trained to generate text that looks like human writing.

Even when you ask them to “output JSON”, they treat the request as another piece of prose.

That means they can:

  • forget a closing brace,
  • insert a stray comma,
  • add explanatory comments that are not valid JSON, or
  • sprinkle extra fields that your code never expected.

Think of an LLM as a chatty friend who tries to be helpful but doesn’t always follow the exact format you need. If you hand that friend a form to fill out, you’ll still need to check the answers before filing them.

In plain English: The model’s “mind” is a string of words, not a data serializer. So you have to make the string conform to a strict schema yourself.

Code: A naive request that often fails

import { Anthropic } from '@anthropic-ai/sdk';

// Create a client – the SDK takes your API key from the ANTHROPIC_API_KEY env var
const client = new Anthropic();

// Prompt that simply says “return JSON”
const prompt = `
You are a task‑list generator. Return JSON that looks like:
{
  "tasks": [
    { "title": string, "due": string, "completed": boolean }
  ]
}
`;

// Call the /messages endpoint (simplified)
const response = await client.completions.create({
  model: 'claude-3-5-sonnet-20240620',
  max_tokens: 500,
  messages: [{ role: 'user', content: prompt }],
});

console.log(response.content); // <-- often malformed JSON!
Enter fullscreen mode Exit fullscreen mode

Running the code above can produce something like:

{
  "tasks": [
    { "title": "Buy milk", "due": "2026‑09‑01", "completed": false, }
  ] // note the extra comma
}
Enter fullscreen mode Exit fullscreen mode

That stray comma will make JSON.parse throw an exception, crashing the Lambda.


Claude’s Structured Output Feature Explained

Claude offers a structured output mode where you describe a JSON schema in the prompt, and the model tries to obey it more strictly. Internally Claude builds a tiny validator and attempts to generate only values that satisfy the description.

The feature works best when you:

  1. Define the schema in plain English (the model still reads it as text).
  2. Ask Claude to “respond only with JSON that matches the schema”.
  3. Add a fallback check because Claude can still slip.

Tip: Think of the structured output prompt as a contract you give Claude. The contract tells Claude what it must deliver, but you still need a quality inspector on the other side.

Known gotchas with @anthropic-ai/sdk

Gotcha Why it matters How to avoid
The SDK returns content as an array of Message objects, not a plain string. Forgetting to extract the text leads to undefined errors. Pull content[0].text (or join all parts).
max_tokens too low can truncate the JSON before the closing brace. Incomplete JSON fails parsing. Give a generous token budget (e.g., 500).
The model may add explanatory sentences before the JSON block. Anything before the opening { breaks JSON.parse. Instruct Claude to “output ONLY the JSON”.
Occasionally Claude adds extra fields that are not in the schema. Your TypeScript type will not match, leading to runtime surprises. Validate with a runtime parser (e.g., Zod) and retry if needed.

Type‑Safe Prompt Templates with the satisfies Operator

TypeScript 4.9 introduced the satisfies operator. It lets you write a literal object once and verify that it conforms to a type without widening the literal’s inferred type. This is perfect for describing a JSON schema inside your code and making sure the prompt you send matches that description.

Why use it?

  • It guarantees that the prompt you type actually matches the TypeScript type you will later enforce on the response.
  • It prevents accidental typos in the schema (e.g., "titel" instead of "title").

Analogy

Imagine you are baking a cake. The satisfies operator is like checking the recipe before you start mixing ingredients: you make sure the list of ingredients you wrote matches the official recipe, so you won’t be surprised by missing flour later.

Code: Defining the schema with satisfies

// 1️⃣ Define the shape we expect from Claude
type Task = {
  title: string;
  due: string;          // ISO‑8601 date string, e.g. "2026-09-01"
  completed: boolean;
};

type TaskList = {
  tasks: Task[];
};

// 2️⃣ Write a plain‑English description that mirrors the type
const taskListSchema = {
  // The outer object must have a single key called "tasks"
  tasks: [
    {
      // Each item in the array must have these three fields
      title: "string",          // any short text
      due: "ISO‑8601 date",     // e.g. "2026-09-01"
      completed: "boolean",    // true or false
    },
  ],
} satisfies TaskList; // ✅ compile‑time check, no runtime cost
Enter fullscreen mode Exit fullscreen mode

If you accidentally wrote "titel" instead of "title", TypeScript would raise an error right away, catching the mistake before Claude ever sees the prompt.


Full‑Stack Example: From Prompt to Typed Response

Below is a complete AWS Lambda handler that:

  1. Builds a prompt using the taskListSchema above.
  2. Calls Claude’s /messages endpoint via @anthropic-ai/sdk.
  3. Extracts the raw string, trims any surrounding text, and parses it.
  4. Validates the parsed object with Zod (a runtime schema library).
  5. Retries once with a stricter prompt if validation fails.

Key takeaway: Combining compile‑time satisfies, runtime Zod validation, and a retry loop gives you a safety net that catches almost every malformed response.

Install the needed packages

npm install @anthropic-ai/sdk zod
Enter fullscreen mode Exit fullscreen mode

Lambda code (fully commented)

import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { Anthropic } from '@anthropic-ai/sdk';
import { z } from 'zod';

// -----------------------------------------------------
// 1️⃣ Type definitions (compile‑time only)
// -----------------------------------------------------
type Task = {
  title: string;
  due: string;          // ISO‑8601 date
  completed: boolean;
};

type TaskList = {
  tasks: Task[];
};

// -----------------------------------------------------
// 2️⃣ Zod schema (runtime validation)
// -----------------------------------------------------
const TaskSchema = z.object({
  title: z.string(),
  due: z.string().refine((s) => !isNaN(Date.parse(s)), {
    message: 'must be a valid ISO‑8601 date',
  }),
  completed: z.boolean(),
});

const TaskListSchema = z.object({
  tasks: z.array(TaskSchema),
});

// -----------------------------------------------------
// 3️⃣ Prompt builder – uses the `satisfies` trick
// -----------------------------------------------------
const taskListSchemaLiteral = {
  tasks: [
    {
      title: 'string',
      due: 'ISO‑8601 date',
      completed: 'boolean',
    },
  ],
} satisfies TaskList; // compile‑time guarantee

function buildPrompt(): string {
  return `
You are a helpful assistant that creates a to‑do list.
Return **only** a JSON object that matches this exact schema:

${JSON.stringify(taskListSchemaLiteral, null, 2)}

The JSON must be the *only* content in your reply. No explanations, no extra fields.
`;
}

// -----------------------------------------------------
// 4️⃣ Helper: call Claude and get raw JSON string
// -----------------------------------------------------
const client = new Anthropic(); // picks up ANTHROPIC_API_KEY automatically

async function callClaude(prompt: string): Promise<string> {
  const response = await client.messages.create({
    model: 'claude-3-5-sonnet-20240620',
    max_tokens: 500,
    temperature: 0, // deterministic output helps with repeatability
    messages: [{ role: 'user', content: prompt }],
  });

  // SDK returns an array of content blocks – join them together
  const raw = response.content
    .map((block) => (typeof block === 'string' ? block : block.text))
    .join('')
    .trim();

  // If Claude added a leading explanation, strip everything before the first '{'
  const jsonStart = raw.indexOf('{');
  return jsonStart >= 0 ? raw.slice(jsonStart) : raw;
}

// -----------------------------------------------------
// 5️⃣ Main Lambda handler
// -----------------------------------------------------
export const handler = async (
  _event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
  // First attempt with the normal prompt
  let attempt = 1;
  let jsonString = await callClaude(buildPrompt());

  // -------------------------------------------------
  // 6️⃣ Runtime validation – Zod will throw if mismatch
  // -------------------------------------------------
  try {
    const parsed = TaskListSchema.parse(JSON.parse(jsonString));
    // If we reach here, the data matches the schema
    return {
      statusCode: 200,
      body: JSON.stringify({ message: 'Success', data: parsed }),
    };
  } catch (err) {
    // -------------------------------------------------
    // 7️⃣ Retry with a stricter prompt if first try fails
    // -------------------------------------------------
    if (attempt === 1) {
      attempt++;
      const stricterPrompt = `
${buildPrompt()}

**IMPORTANT**: Do not add any comments, trailing commas, or extra whitespace. 
If you are unsure, return an empty array for "tasks" instead of malformed JSON.
`;
      jsonString = await callClaude(stricterPrompt);
      try {
        const parsed = TaskListSchema.parse(JSON.parse(jsonString));
        return {
          statusCode: 200,
          body: JSON.stringify({ message: 'Success (after retry)', data: parsed }),
        };
      } catch (secondErr) {
        // -------------------------------------------------
        // 8️⃣ Final fallback – manual sanitization
        // -------------------------------------------------
        const cleaned = jsonString
          .replace(/,\s*}/g, '}')          // remove trailing commas before }
          .replace(/,\s*]/g, ']')          // remove trailing commas before ]
          .replace(/\/\/.*$/gm, '')        // strip line‑comments if any
          .trim();

        try {
          const parsed = TaskListSchema.parse(JSON.parse(cleaned));
          return {
            statusCode: 200,
            body: JSON.stringify({
              message: 'Success (after cleanup)',
              data: parsed,
            }),
          };
        } catch (_) {
          // If we still can't parse, return a clear error
          return {
            statusCode: 500,
            body: JSON.stringify({
              error: 'Failed to obtain valid JSON from Claude after two attempts.',
              rawResponse: jsonString,
            }),
          };
        }
      }
    }

    // If we get here (should not happen), report the error
    return {
      statusCode: 500,
      body: JSON.stringify({
        error: 'Validation failed on first attempt.',
        details: (err as Error).message,
      }),
    };
  }
};
Enter fullscreen mode Exit fullscreen mode

What the code does, step by step

Step Reason
Define TypeScript types (Task, TaskList) Gives you static typing throughout the project.
Create Zod schemas (TaskSchema, TaskListSchema) Provides a runtime check that catches malformed JSON.
Build prompt with satisfies Guarantees the prompt matches the expected shape before you even send it.
Trim non‑JSON text (jsonStart) Removes any stray prose Claude might add.
First parse attempt Most of the time the model obeys the contract.
Retry with stricter wording Helps when the model adds a trailing comma or extra field.
Manual cleanup (replace calls) A last‑ditch effort before giving up.
Return clear HTTP responses Makes downstream services know exactly what happened.

Tip: Setting temperature: 0 reduces randomness, making Claude more likely to stick to the schema you described.


Debugging Gotchas and When to Fall Back to Manual Validation

Even with the safeguards above, you’ll occasionally see weird output. Below are the most common patterns and how to handle them.

1️⃣ Extra fields that weren’t in the schema

Claude may add a "priority" field because it thinks it helps the user. Zod’s strict() mode will reject the object, triggering the retry logic.

const StrictTaskListSchema = TaskListSchema.strict(); // rejects unknown keys
Enter fullscreen mode Exit fullscreen mode

If you do want to keep unknown keys, use .passthrough() instead, but be aware that your downstream code must ignore them.

2️⃣ Stray commas before closing braces

The regular expression /,\\s*}/g removes a comma that appears just before a }. It’s safe because JSON never requires a comma there.

3️⃣ Claude returns a string that looks like JSON but is wrapped in backticks

\`\`\`json
{ "tasks": [] }
\`\`\`
Enter fullscreen mode Exit fullscreen mode

Strip Markdown fences before parsing:

jsonString = jsonString.replace(/^```
{% endraw %}
json\s*|
{% raw %}
```$/g, '').trim();
Enter fullscreen mode Exit fullscreen mode

4️⃣ The response is split across multiple content blocks

The SDK concatenates them with join(''). If you ever see a missing bracket, double‑check that all blocks were included.

5️⃣ Timeout or token limit

If Claude runs out of tokens, the JSON may be cut off. Increase max_tokens or ask the model to “keep the list short (max 5 items)”.

In plain English: Expect the model to be a bit chatty. Your job is to keep the conversation focused, then clean up what it says before you trust it.


The Takeaway

Key points you can apply right now

  • LLMs generate text, not data structures; always plan for a validation step.
  • Use Claude’s structured‑output prompt to ask for a schema, but never rely on it alone.
  • The TypeScript satisfies operator lets you write the schema once and check it at compile time, preventing mismatched prompts.
  • Pair compile‑time types with a runtime validator like Zod to catch stray commas, extra fields, or malformed dates.
  • Implement a small retry loop with a stricter prompt; most failures are corrected on the second try.
  • Keep a final manual‑sanitization fallback so your Lambda never crashes because of a stray character.

With these practices, you can treat Claude as a reliable JSON producer and build production‑grade automation that stays up even when the model gets a little chatty. Happy prompting!


Transparency notice

This article was written with the help of an AI system — Groq (GPT OSS 120B).

Published: 2026-08-25 · Primary focus: PromptEngineering

All code blocks are intended to be correct and runnable, but please verify them
against Anthropic's prompt engineering guide before using in production.

Find an error? Drop a comment — corrections are always welcome.

Source: dev.to

arrow_back Back to Tutorials