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!
Running the code above can produce something like:
{
"tasks": [
{ "title": "Buy milk", "due": "2026‑09‑01", "completed": false, }
] // note the extra comma
}
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:
- Define the schema in plain English (the model still reads it as text).
- Ask Claude to “respond only with JSON that matches the schema”.
- 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
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:
- Builds a prompt using the
taskListSchemaabove. - Calls Claude’s
/messagesendpoint via@anthropic-ai/sdk. - Extracts the raw string, trims any surrounding text, and parses it.
- Validates the parsed object with Zod (a runtime schema library).
- 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
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,
}),
};
}
};
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: 0reduces 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
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": [] }
\`\`\`
Strip Markdown fences before parsing:
jsonString = jsonString.replace(/^```
{% endraw %}
json\s*|
{% raw %}
```$/g, '').trim();
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
satisfiesoperator 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.
Top comments (0)