Skip to Content

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:

  1. Lenient parsing (LlmJson.parse) β€” accept malformed JSON that JSON.parse would reject (unclosed brackets, trailing commas, markdown fences, …)
  2. Type coercion (LlmJson.parse with a schema, or LlmJson.coerce on an already-parsed object) β€” fix value shapes against the schema ("42" β†’ 42, double-stringified objects β†’ real objects)
  3. 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.

@typia/utils β€” what's in LlmJson
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

@typia/utils
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 schema

The 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:

examples/src/llm/application-validate.ts
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.price so 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 undefined with 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:

  1. Lenient JSON parsing β€” accepts JSON that JSON.parse would reject
  2. Type coercion β€” fixes values where the type doesn’t quite match the schema
examples/src/llm/application-parse.ts
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

IssueExampleRecovery
Unclosed brackets{"name": "John"Parses available content
Unclosed strings{"name": "JohnReturns 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": truCompletes to true
Junk prefixHere 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" β†’ 42 when schema expects number or integer
  • "true" / "false" β†’ true / false when schema expects boolean
  • "null" β†’ null when schema expects null
  • "{...}" β†’ parsed object when schema expects object
  • "[...]" β†’ parsed array when schema expects array
  • 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 variant

parameters 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): T

Use coerce when the JSON has already been parsed for you β€” typically because the SDK did it.

When to use coerce vs parse

ScenarioUse
Raw JSON string from the LLMLlmJson.parse(text, schema)
SDK returned a JS object (Anthropic, Vercel AI, LangChain, MCP)LlmJson.coerce(obj, schema)
Streaming JSON assembled incrementallyLlmJson.coerce(obj, schema)
WebSocket message already parsedLlmJson.coerce(obj, schema)

coerce is the same logic as parse, minus the lenient JSON layer.

examples/src/llm/application-coerce.ts
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:

  1. Resolve $ref β€” follow schema references
  2. anyOf with discriminator β€” pick the right variant using x-discriminator
  3. String-as-JSON β€” if value is a string but schema expects a non-string, try a lenient JSON parse
  4. Recursive descent β€” coerce nested objects and arrays based on properties and items
  5. 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>
@typia/utils - LlmJson.validate
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

ScenarioUse
Schema comes from a remote registry / databaseLlmJson.validate
You’re building a generic library that accepts any LLM schemaLlmJson.validate
You have a TypeScript type at compile timetypia.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: false

Prefer 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:

validation-feedback-loop.ts
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:

  1. parse the LLM’s response (handles malformed JSON + coerces types)
  2. validate to check field constraints
  3. If validation fails, format the errors with LlmJson.stringify and send them back
  4. Repeat until validation passes (usually one or two turns)

Where to go next

Last updated on