- Book: AI That Answers
- The series: AI in TypeScript — 5 books, from your first LLM call to agents in production — all five here
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You have a TypeScript codebase with strict on. You have Zod on your
HTTP boundary. You have a database client that generates types from
the schema. Nothing crosses into your domain without a shape.
Then you add an LLM call, and the discipline stops at the door.
const res = await client.messages.create({ /* ... */ });
const text = res.content[0].text;
const parsed = JSON.parse(text);
await db.invoice.update({
where: { id },
data: { total: parsed.total, status: parsed.status },
});
That is a strict-mode codebase writing any into a money column.
The compiler is fine with it. It will stay fine with it right up
until the model returns "total": "1,240.00" and Postgres stores a
number it did not expect, or returns status: "PAID" where your enum
says paid, or omits total entirely because the invoice image was
blurry.
The problem is not that the model is unreliable. The problem is that
you removed the one mechanism your codebase uses to survive
unreliable inputs.
Where the type actually disappears
Walk the chain and find the exact line where you stopped knowing
things.
res.content is a union. A content block is a text block or a tool
use block or a thinking block, depending on what the model did. So
res.content[0] is a union member, and reaching for .text on it is
already a narrowing you have not performed. Most SDKs will make you
do this — and most people reach for a non-null assertion instead.
// the shortcut everyone writes first
const text = (res.content[0] as { text: string }).text;
That cast is a lie you tell the compiler. If the model opened with a
tool use block, text is undefined at runtime and your error is a
JSON.parse failure fifty lines away from the cause.
Then JSON.parse returns any. Not unknown — any. Every
property access on the result type-checks. parsed.total,
parsed.totl, parsed.total.amount.value — all fine, all compile,
all potentially undefined at runtime.
So you have two separate losses stacked: an unnarrowed union at the
SDK boundary, and any from the parser. The second one is worse,
because any is contagious. It flows into data, into your update
call, into whatever you build on top.
Narrow the content block first
Before you get to schemas, fix the union. This is plain TypeScript
and it costs you four lines.
import type { ContentBlock } from "@anthropic-ai/sdk/resources";
function textOf(blocks: ContentBlock[]): string {
const parts = blocks
.filter((b): b is Extract<ContentBlock, { type: "text" }> =>
b.type === "text")
.map((b) => b.text);
if (parts.length === 0) {
throw new NoTextContent(blocks.map((b) => b.type));
}
return parts.join("");
}
Two things worth noticing. The predicate b is Extract<...> is what
makes the filter narrow rather than just filter — without it you
get ContentBlock[] back and .text still fails. And the throw is
typed and specific: it carries the block types you actually received,
so the error message tells you the model returned tool_use instead
of leaving you guessing.
Joining rather than taking [0] matters too. A model can emit
several text blocks in one response, and taking the first silently
truncates.
Put a schema at the boundary
Now the parse. The rule is the same one you already apply to a
webhook payload: nothing enters the domain as any.
import { z } from "zod";
const Invoice = z.object({
total: z.number().positive(),
currency: z.enum(["EUR", "USD", "GBP"]),
status: z.enum(["draft", "sent", "paid"]),
dueDate: z.coerce.date(),
lineItems: z.array(
z.object({ label: z.string(), amount: z.number() }),
).min(1),
});
export type Invoice = z.infer<typeof Invoice>;
z.infer is the part that pays. You write the schema once and get a
compile-time type that cannot drift from the runtime check, because
they are the same object. Add a field to the schema and every
consumer that destructures the result updates in the compiler.
A few choices in there are deliberate:
z.coerce.date() accepts the ISO string a model actually returns and
gives you a Date. Without coercion you get a string typed as a
string and a bug the first time someone calls .getTime().
.min(1) on lineItems encodes a domain rule. A model that could
not read the document will happily return an empty array, and an
empty array is not a parse error unless you say so.
z.enum on status is the difference between catching "PAID" at
the boundary and writing it into a column your application later
reads back and fails to match.
Parse, do not validate
The call site is where this becomes ordinary code again.
async function extractInvoice(doc: string): Promise<Invoice> {
const res = await client.messages.create({
model: "claude-opus-5",
max_tokens: 1024,
messages: [{ role: "user", content: prompt(doc) }],
});
const raw: unknown = JSON.parse(textOf(res.content));
return Invoice.parse(raw);
}
Note const raw: unknown. Annotating the JSON.parse result as
unknown is a one-word change that stops any from escaping. The
compiler now refuses every property access on raw until it has been
through Invoice.parse, which is exactly the pressure you want.
The function returns Promise<Invoice>. Callers get a real type.
Nothing downstream needs to know an LLM was involved, which is the
point — the model is an implementation detail of this one function,
not a property of your whole call graph.
What to do when parsing fails
A schema that throws on bad input has moved the failure earlier,
which is progress, but a thrown exception is not a strategy.
The useful move is to feed the validation error back to the model.
Zod errors are structured, so you can hand over something precise
rather than "that was wrong."
async function extractWithRetry(
doc: string,
attempts = 2,
): Promise<Invoice> {
const messages: MessageParam[] = [
{ role: "user", content: prompt(doc) },
];
for (let i = 0; i <= attempts; i++) {
const res = await client.messages.create({
model: "claude-opus-5",
max_tokens: 1024,
messages,
});
const text = textOf(res.content);
const result = Invoice.safeParse(JSON.parse(text));
if (result.success) return result.data;
messages.push({ role: "assistant", content: text });
messages.push({
role: "user",
content:
"That did not match the schema. Fix these fields:\n" +
result.error.issues
.map((i) => `- ${i.path.join(".")}: ${i.message}`)
.join("\n"),
});
}
throw new ExtractionFailed(doc);
}
safeParse instead of parse is what makes this readable — you get
a discriminated union back rather than control flow through
exceptions. And the retry message names the exact paths that failed,
which is a much stronger correction signal than restating the whole
schema.
Cap the attempts. A retry loop with no ceiling is how a single
malformed document turns into a bill.
The rule, stated once
Treat the model like any other untrusted input source. You would not
write JSON.parse(req.body) and pass the result into your ORM. The
LLM response deserves the same suspicion and the same boundary, and
in TypeScript that boundary costs about fifteen lines.
Everything after the boundary is normal code with normal types. That
is the whole benefit: the uncertainty stays in one function instead
of leaking into every file that touches the result.
If this was useful
AI That Answers is the first
book in the series, and this boundary is most of what it argues for:
your first LLM app is an ordinary TypeScript app with one unusual
input, and the sooner you treat it that way the less of it you have
to rewrite later. It covers prompts, structured output, and what
every token costs, in Node and in the browser.
The full series — from this first call through RAG, tool calling,
stateful agents, and shipping — is at
xgabriel.com/ai-in-typescript.



Top comments (0)