LlmJson β the function calling harness primitives
LLM output is messy. Even capable models routinely produce:
- JSON wrapped in a markdown fence
- Unclosed brackets, trailing commas, comments
- Numbers as strings (
"42"), booleans as strings ("true") - Double-stringified objects (
"\"{\\\"x\\\":1}\"") - Extra prose at the start before the JSON begins
LlmJson is a namespace from @typia/utils that handles every one of these and produces typed data. Itβs the engine behind ILlmFunction.parse, ILlmFunction.coerce, and the parse/coerce methods on structuredOutput β and you can also call it directly when youβre working schema-first.
It fixes the LLM output in three layers, each callable independently:
- Lenient parsing (
LlmJson.parse) β accept malformed JSON thatJSON.parsewould reject (unclosed brackets, trailing commas, markdown fences, β¦) - Type coercion (
LlmJson.parsewith a schema, orLlmJson.coerceon an already-parsed object) β fix value shapes against the schema ("42"β42, double-stringified objects β real objects) - Feedback formatting (
LlmJson.stringify) β when validation still fails after parsing + coercing, write an LLM-readable diff for the next turn
LlmJson.validate is the fourth function but it sits on the boundary, not in the loop β it gives you a runtime validator from a schema, which feeds layer 3.
export namespace LlmJson {
// Format a validation failure as annotated JSON for LLM auto-correction
function stringify(failure: IValidation.IFailure): string;
// Lenient JSON parser + type coercion. Returns IJsonParseResult<T>.
function parse<T>(
input: string,
parameters?: ILlmSchema.IParameters,
): IJsonParseResult<T>;
// Type coercion only, for objects already parsed by the SDK.
function coerce<T>(
input: unknown,
parameters: ILlmSchema.IParameters,
): T;
// Build a reusable validator from an LLM parameters schema.
function validate(
parameters: ILlmSchema.IParameters,
equals?: boolean,
): (value: unknown) => IValidation<unknown>;
}undefined
export namespace LlmJson {
export function stringify(failure: IValidation.IFailure): string;
export function parse<T>(
input: string,
parameters?: ILlmSchema.IParameters,
): IJsonParseResult<T>;
export function coerce<T>(
input: unknown,
parameters: ILlmSchema.IParameters,
): T;
export function validate(
parameters: ILlmSchema.IParameters,
equals?: boolean,
): (value: unknown) => IValidation<unknown>;
}Already using typia.llm.application or structuredOutput?
The ILlmFunction and ILlmStructuredOutput objects expose parse, coerce, and validate pre-bound to their parameter schemas. You donβt have to import LlmJson to use them:
const app = typia.llm.application<MyClass>();
const func = app.functions[0];
func.parse(jsonString); // LlmJson.parse + bound schema
func.coerce(parsedObject); // LlmJson.coerce + bound schema
func.validate(args); // typia.validate + bound schemaThe only LlmJson.* function that doesnβt have a built-in equivalent is stringify β thatβs what you import from @typia/utils for the feedback loop.
LlmJson.stringify
When validate reports failures, you want to tell the LLM what it did wrong in a form it understands. LlmJson.stringify(failure) produces annotated JSON with the original data and inline // β comments pointing at each error:
import { LlmJson } from "@typia/utils";
import typia, { ILlmApplication, ILlmFunction, IValidation, tags } from "typia";
const app: ILlmApplication = typia.llm.application<OrderService>();
const func: ILlmFunction = app.functions[0];
// LLM generated invalid data
const input = {
order: {
payment: { type: "card", cardNumber: 12345678 }, // should be string
product: {
name: "Laptop",
price: -100, // violates Minimum<0>
quantity: 2.5, // should be uint32
},
customer: {
name: "John Doe",
email: "invalid-email", // violates Format<"email">
vip: "yes", // should be boolean
},
},
};
// Validate and format errors for LLM feedback
const result: IValidation = func.validate(input);
if (result.success === false) {
const feedback: string = LlmJson.stringify(result);
console.log(feedback);
}
interface IOrder {
payment: IPayment;
product: {
name: string;
price: number & tags.Minimum<0>;
quantity: number & tags.Type<"uint32">;
};
customer: {
name: string;
email: string & tags.Format<"email">;
vip: boolean;
};
}
type IPayment =
| { type: "card"; cardNumber: string }
| { type: "bank"; accountNumber: string };
declare class OrderService {
/**
* Create a new order.
*
* @param props Order properties
*/
createOrder(props: { order: IOrder }): { id: string };
}{
"order": {
"payment": {
"type": "card",
"cardNumber": 12345678 // β [{"path":"$input.order.payment.cardNumber","expected":"string"}]
},
"product": {
"name": "Laptop",
"price": -100, // β [{"path":"$input.order.product.price","expected":"number & Minimum<0>"}]
"quantity": 2.5 // β [{"path":"$input.order.product.quantity","expected":"number & Type<\"uint32\">"}]
},
"customer": {
"name": "John Doe",
"email": "invalid-email", // β [{"path":"$input.order.customer.email","expected":"string & Format<\"email\">"}]
"vip": "yes" // β [{"path":"$input.order.customer.vip","expected":"boolean"}]
}
}
}What the format does:
- Inline annotations β each error sits next to the offending value, prefixed with
// β - Full path β
$input.order.product.priceso the LLM knows exactly where to fix - Expected type β the same intersection syntax typia uses everywhere (
number & Minimum<0>) - Missing properties β required properties that are absent are written as
undefinedwith the same annotation - Nested correctly β works at any depth
- Unmappable errors β if an error canβt be placed inline (e.g. the whole root is the wrong type), itβs appended as a separate block
Drop that string into a system message (βyou made these mistakes, please correct themβ) and the LLM will fix the problems on the next turn.
LlmJson.parse
LlmJson.parse<T>(input: string, parameters?: ILlmSchema.IParameters)
: IJsonParseResult<T>parse does two things in one call:
- Lenient JSON parsing β accepts JSON that
JSON.parsewould reject - Type coercion β fixes values where the type doesnβt quite match the schema
import { dedent } from "@typia/utils";
import typia, { ILlmApplication, ILlmFunction, tags } from "typia";
const app: ILlmApplication = typia.llm.application<OrderService>();
const func: ILlmFunction = app.functions[0];
// LLM sometimes returns malformed JSON with wrong types
const llmOutput = dedent`
> LLM sometimes returns some prefix text with markdown JSON code block.
I'd be happy to help you with your order! π
\`\`\`json
{
"order": {
"payment": "{\"type\":\"card\",\"cardNumber\":\"1234-5678", // unclosed string & bracket
"product": {
name: "Laptop", // unquoted key
price: "1299.99", // wrong type (string instead of number)
quantity: 2, // trailing comma
},
"customer": {
// incomplete keyword + unclosed brackets
"name": "John Doe",
"email": "john@example.com",
vip: tru
\`\`\`
`;
const result = func.parse(llmOutput);
if (result.success) console.log(result);
interface IOrder {
payment: IPayment;
product: {
name: string;
price: number & tags.Minimum<0>;
quantity: number & tags.Type<"uint32">;
};
customer: {
name: string;
email: string & tags.Format<"email">;
vip: boolean;
};
}
type IPayment =
| { type: "card"; cardNumber: string }
| { type: "bank"; accountNumber: string };
declare class OrderService {
/**
* Create a new order.
*
* @param props Order properties
*/
createOrder(props: { order: IOrder }): { id: string };
}Lenient JSON features
| Issue | Example | Recovery |
|---|---|---|
| Unclosed brackets | {"name": "John" | Parses available content |
| Unclosed strings | {"name": "John | Returns partial string |
| Trailing commas | [1, 2, 3, ] | Ignores trailing comma |
| JavaScript comments | {"a": 1 /* comment */} | Strips comments |
| Unquoted keys | {name: "John"} | Accepts identifier-style keys |
| Incomplete keywords | {"done": tru | Completes to true |
| Junk prefix | Here is your JSON: {"a": 1} | Skips to first { or [ |
| Markdown blocks | ```json\n{"a": 1}\n``` | Extracts the content |
| Unicode surrogates | "π" | Handles emoji correctly |
Type coercion
When you pass parameters, the parser also fixes values based on what the schema expects:
// LLM returned: { "count": "42", "active": "true", "data": "{\"x\": 1}" }
// After coercion: { "count": 42, "active": true, "data": { x: 1 } }Coercion rules:
"42"β42when schema expectsnumberorinteger"true"/"false"βtrue/falsewhen schema expectsboolean"null"βnullwhen schema expectsnull"{...}"β parsed object when schema expectsobject"[...]"β parsed array when schema expectsarray- Works recursively on nested objects and arrays
Discriminated unions
For anyOf schemas with a discriminator, coercion uses the discriminator to pick the right variant:
// Schema: anyOf [{ type: "dog", bark: boolean }, { type: "cat", meow: boolean }]
// x-discriminator: { propertyName: "type" }
// Input: { type: "dog", bark: "true" }
// Result: { type: "dog", bark: true } β coerced using the dog variantparameters is optional.
If you omit the parameters argument, LlmJson.parse still does the lenient JSON parsing β you just skip type coercion. Use this if you only need the parser tolerance, not the schema-aware coercion.
parse does not validate.
Lenient parsing + coercion get the shape close. But they donβt enforce things like βthis number must be β₯ 0β or βthis string must be a valid emailβ. Always follow parse with typia.validate (or func.validate / output.validate).
LlmJson.coerce
LlmJson.coerce<T>(input: unknown, parameters: ILlmSchema.IParameters): TUse coerce when the JSON has already been parsed for you β typically because the SDK did it.
When to use coerce vs parse
| Scenario | Use |
|---|---|
| Raw JSON string from the LLM | LlmJson.parse(text, schema) |
| SDK returned a JS object (Anthropic, Vercel AI, LangChain, MCP) | LlmJson.coerce(obj, schema) |
| Streaming JSON assembled incrementally | LlmJson.coerce(obj, schema) |
| WebSocket message already parsed | LlmJson.coerce(obj, schema) |
coerce is the same logic as parse, minus the lenient JSON layer.
import typia, { ILlmApplication, ILlmFunction, tags } from "typia";
const app: ILlmApplication = typia.llm.application<OrderService>();
const func: ILlmFunction = app.functions[0];
// Anthropic, Vercel AI, LangChain, MCP already parse JSON internally.
// However, types are often wrong:
const fromSdk = {
order: {
payment: '{"type":"card","cardNumber":"1234-5678', // stringified (unclosed)
product: {
name: "Laptop",
price: "1299.99", // string instead of number
quantity: "2", // string instead of number
},
customer: {
name: "John Doe",
email: "john@example.com",
vip: "true", // string instead of boolean
},
},
};
const result = func.coerce(fromSdk);
console.log(result);
interface IOrder {
payment: IPayment;
product: {
name: string;
price: number & tags.Minimum<0>;
quantity: number & tags.Type<"uint32">;
};
customer: {
name: string;
email: string & tags.Format<"email">;
vip: boolean;
};
}
type IPayment =
| { type: "card"; cardNumber: string }
| { type: "bank"; accountNumber: string };
declare class OrderService {
/**
* Create a new order.
*
* @param props Order properties
*/
createOrder(props: { order: IOrder }): { id: string };
}Coercion behavior
What coerce actually does, step by step:
- Resolve
$refβ follow schema references anyOfwith discriminator β pick the right variant usingx-discriminator- String-as-JSON β if value is a string but schema expects a non-string, try a lenient JSON parse
- Recursive descent β coerce nested objects and arrays based on
propertiesanditems - Leave unknown extras alone β extras pass through; validation later decides whether to accept them
LlmJson.validate
LlmJson.validate(parameters: ILlmSchema.IParameters, equals?: boolean)
: (value: unknown) => IValidation<unknown>import typia, { IValidation } from "typia";
import { LlmJson } from "@typia/utils";
interface IMember {
name: string;
age: number;
}
const parameters = typia.llm.parameters<IMember>();
// Reusable validator from the schema
const validate = LlmJson.validate(parameters);
// Validate data
const result: IValidation = validate({ name: "John", age: "thirty" });
if (!result.success) {
console.log(LlmJson.stringify(result));
}LlmJson.validate is the schema-first counterpart to typia.validate<T> β it builds a validator at runtime from an OpenAPI / LLM schema, rather than from a TypeScript type at compile time.
When to use it
| Scenario | Use |
|---|---|
| Schema comes from a remote registry / database | LlmJson.validate |
| Youβre building a generic library that accepts any LLM schema | LlmJson.validate |
| You have a TypeScript type at compile time | typia.validate<T> β itβs faster and the messages are better |
Strict mode
equals = true makes the validator reject objects with extra properties:
const lenient = LlmJson.validate(parameters);
lenient({ name: "John", age: 25, extra: "ignored" }); // success: true
const strict = LlmJson.validate(parameters, true);
strict({ name: "John", age: 25, extra: "ignored" }); // success: falsePrefer the compile-time validator when you can.
typia.validate<T> is AOT-compiled and emits inline checks per field β significantly faster than LlmJson.validate, which has to interpret the schema at runtime. Reach for LlmJson.validate only when the TypeScript type isnβt available where you need to validate.
Feedback loop
The four functions form a deterministic correction loop around the probabilistic LLM:
import OpenAI from "openai";
import typia, { ILlmApplication, IValidation, tags } from "typia";
import { LlmJson } from "@typia/utils";
interface IMember {
email: string & tags.Format<"email">;
name: string;
age: number;
hobbies: string[];
joined_at: string & tags.Format<"date">;
}
class MemberService {
/** Register a new member to the community. */
register(member: IMember): void {
console.log("Registered:", member);
}
}
const app: ILlmApplication = typia.llm.application<MemberService>();
const func = app.functions[0]; // register
const step = async (
client: OpenAI,
failure?: IValidation.IFailure,
): Promise<IValidation<IMember>> => {
const completion = await client.chat.completions.create({
model: "gpt-4o",
messages: [
{
role: "user",
content: [
"I am a new member of the community.",
"My name is John Doe, and I am 25 years old.",
"I like playing basketball and reading books,",
"and joined to this community at 2022-01-01.",
].join("\n"),
},
// Feed the previous turn's mistakes back, if any.
...(failure
? [{
role: "system" as const,
content: [
"You made type mistakes on the previous turn.",
"Here is the structured error report. Correct each one.",
"",
LlmJson.stringify(failure),
].join("\n"),
}]
: []),
],
response_format: {
type: "json_schema",
json_schema: { name: "member", schema: func.parameters as any },
},
});
const parsed = func.parse(completion.choices[0].message.content!);
if (!parsed.success) return { success: false, data: parsed.data, errors: [] };
return func.validate(parsed.data);
};
const main = async (): Promise<void> => {
const client = new OpenAI({ apiKey: "<YOUR_API_KEY>" });
let result: IValidation<IMember> | undefined;
for (let i = 0; i < 3; ++i) {
result = await step(client, result?.success === false ? result : undefined);
if (result.success) break;
}
console.log(result);
};This is the same loop that powered AutoBeΒ to 100% function calling correctness on compiler AST types β across four different Qwen models, against a raw baseline of 6.75%.
The recipe is the same every time:
parsethe LLMβs response (handles malformed JSON + coerces types)validateto check field constraints- If validation fails, format the errors with
LlmJson.stringifyand send them back - Repeat until validation passes (usually one or two turns)
Where to go next
- The class-based tool API that wires this loop automatically β
typia.llm.application - Single-shape variant (no method-selection layer) β
typia.llm.structuredOutput - OpenAPI source instead of TypeScript class β
HttpLlm - The validator on its own β
typia.validate