typia.llm.application โ turn a TypeScript class into LLM tools
This is the class-based LLM tool API โ for when the LLM picks one of several methods and fills its arguments. If you just want one specific JSON shape out of the LLM (no method selection), use structuredOutput. If you want LLM tools generated from an existing REST API, use HttpLlm.
Modern LLMs (OpenAI, Anthropic, Gemini, โฆ) can decide on their own when to call a function and how to fill its arguments โ provided you describe the functionโs signature in JSON Schema. That description is what โfunction callingย โ needs.
typia.llm.application<Class>() generates that description automatically. You write a normal TypeScript class with normal TypeScript types and JSDoc comments. typia turns each method into a tool, with parameters typed as JSON Schema and validation, parsing, and type coercion already wired up.
import typia from "typia";
class BbsArticleService {
/** Create a new article. */
create(props: { input: { title: string; body: string } }): Promise<{ id: string }> { /* โฆ */ }
/** List recent articles. */
recent(props: { limit: number }): Promise<{ items: Array<{ id: string; title: string }> }> { /* โฆ */ }
}
const app = typia.llm.application<BbsArticleService>();
// app.functions[0] = { name: "create", parameters: {...JSON Schema...}, parse, coerce, validate, โฆ }
// app.functions[1] = { name: "recent", ... }Hand app.functions (or one of the framework adapters below) to your LLM SDK and the model can now invoke create and recent with arguments it composes from natural language.
undefined
export namespace llm {
export function application<
Class extends Record<string, any>,
Config extends Partial<ILlmSchema.IConfig & { equals: boolean }> = {},
>(
config?: Partial<Pick<ILlmApplication.IConfig<Class>, "validate">>,
): ILlmApplication<Class>;
}Quick glossary โ function calling vs. structured output
- Function calling: the LLM picks one of several functions and fills its arguments. You execute the function and feed the result back. (OpenAI docsย )
- Structured output: the LLM doesnโt pick anything โ it just emits one specific JSON shape you asked for. (OpenAI docsย )
typia.llm.application is for function calling. For structured output, use typia.llm.structuredOutput<T>().
Full source
TypeScript Source
import typia, { ILlmApplication } from "typia";
import { BbsArticleService } from "./BbsArticleService";
const app: ILlmApplication = typia.llm.application<BbsArticleService>();
console.log(app);Restrictions
The compile-time transform enforces a small set of method-shape rules on every method. Getting this right is the #1 reason a new typia.llm.application<Class>() call fails to compile, so read it before anything else:
- Each method takes exactly one keyworded-object parameter. LLMs invoke functions with named arguments, so
add(x: number, y: number)is rejected. Wrap the args:add(props: { x: number; y: number }). - Each method returns a keyworded-object type (e.g.
{ value: number },{ items: T[] }) orvoid. Primitives, arrays, andT | undefinedare rejected. If you need to return one, wrap it. - Object parameter and return types may not have dynamic keys.
Record<string, number>wonโt work โ useArray<{ key: string; value: number }>.
Source That Wonโt Compile
import typia, { ILlmApplication, tags } from "typia";
interface BbsArticleController {
/** Create a new article and return it. */
create(props: { input: IBbsArticle.ICreate }): Promise<IBbsArticle | undefined>;
// ^ return must not be union with undefined
/** Add two numbers. */
add(props: { x: number; y: number }): number;
// ^ return must be an object
erase(id: string & tags.Format<"uuid">): Promise<void>;
// ^ parameter must be a keyworded object, not a primitive
}
const app: ILlmApplication = typia.llm.application<BbsArticleController>();For the deeper type-level rules see llm.parameters and llm.schema.
Framework adapters
typia.llm.controller<Class>(name, instance) wraps the schema together with an executable instance. You pass the controller to an adapter and the adapter does the wiring:
Vercel AI SDK
npm i @ai-sdk/openai @typia/vercel aiimport { openai } from "@ai-sdk/openai";
import { toVercelTools } from "@typia/vercel";
import { generateText, Tool } from "ai";
import typia from "typia";
import { BbsArticleService } from "./BbsArticleService";
const tools: Record<string, Tool> = toVercelTools({
controllers: [
typia.llm.controller<BbsArticleService>("bbs", new BbsArticleService()),
],
});
const result = await generateText({
model: openai("gpt-4o"),
tools,
prompt: "I want to create a new article about TypeScript",
});Same controller, three different transports. Pick whichever your project already uses.
The function calling harness
LLM output is unreliable. Even capable models routinely:
- close a JSON object with the wrong bracket
- wrap their answer in a
```jsonmarkdown block - return
"42"when the schema expects anumber - emit a double-stringified object:
"\"{\\\"x\\\":1}\""instead of{ x: 1 }
The ILlmFunction typia generates carries three methods:
parse(text)โ lenient JSON parsing with type coercion based on the schemacoerce(obj)โ type coercion only (use when the SDK already JSON-parsed for you)validate(obj)โ full schema validation; returnsIValidation<unknown>
Plus a fourth helper that lives on @typia/utils because it doesnโt depend on a specific functionโs schema:
LlmJson.stringify(failure)โ format the validatorโs errors as annotated JSON the LLM can read
Together they form a deterministic correction loop around the probabilistic LLM:
LLM text โโ parse() โโ coerced object
โ
โผ
validate()
โ
โโโโโโโโโโโโโดโโโโโโโโโโโโโ
โผ โผ
success failure
โ โ
โผ โผ
execute stringify errors back
to the LLM, retrySee LLM JSON utilities for the full mechanics โ lenient features supported, coercion rules, and the feedback-loop pattern. The same machinery powers structuredOutput, parameters, and the HTTP-based HttpLlm.controller.
Parse and coerce in practice
Parsing (text โ object)
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 };
}Which one do you call โ parse or coerce?
- LLM gives you a raw string (OpenAI
chat.completionscontent, etc.) โ callparse(text). - SDK already JSON-parsed for you (Anthropic, Vercel AI, LangChain, MCP) โ call
coerce(obj).
parse is just coerce with lenient JSON parsing in front. Run only one.
Double-stringified objects are common โ and the coercion layer rescues them
Many LLMs emit a JSON-stringified value where the schema expects an object โ "{\"x\":1}" instead of { x: 1 }. Without coercion, validation rejects every one of those responses. With parse/coerce the schema-aware coercion descends through the string-wrapped layers and produces typed data. The AutoBe production data below shows the same kind of effect on much harder structures.
Validation feedback
After parse/coerce, run validate to verify constraints (formats, ranges, lengths). On failure, format the errors with LlmJson.stringify from @typia/utils โ the output is annotated JSON the LLM can read and self-correct:
import { LlmJson } from "@typia/utils";
// then use LlmJson.stringify(validationFailure) inside the snippet belowimport { 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"}]
}
}
}Send that block back as a system message (โyou made these mistakes โ fix themโ). The model reads the inline annotations and corrects on the next turn. The full pattern lives at LlmJson.
Real-world numbers
In AutoBeย โ an AI backend generator built by Wrtn Technologies โ qwen3-coder-next produced 6.75% correct function calls on compiler AST types when used naively. With the typia harness wrapping it, the success rate hit 100% across all four tested Qwen models.
AutoBe once shipped a build with the system prompt completely empty. Nobody noticed โ the types were doing the work the prompt would have done.
// Unlimited unions ร unlimited depth ร recursive references
export type IExpression =
| IBooleanLiteral
| INumericLiteral
| IStringLiteral
| IArrayLiteralExpression // recursive (contains IExpression[])
| IObjectLiteralExpression // recursive
| INullLiteral
| IUndefinedKeyword
| IIdentifier
| IPropertyAccessExpression // recursive
| IElementAccessExpression // recursive
| ITypeOfExpression // recursive
| IPrefixUnaryExpression // recursive
| IPostfixUnaryExpression // recursive
| IBinaryExpression // recursive (left & right)
| IArrowFunction // recursive (body is IExpression)
| ICallExpression // recursive (args)
| INewExpression // recursive
| IConditionalPredicate // recursive (then & else)
| ... // 30+ expression types totalStrict mode
By default the auto-generated ILlmFunction.validate is lenient: arguments with extra (unexpected) properties pass validation. To reject them, set the equals: true config:
const app = typia.llm.application<BbsArticleService, { equals: true }>();{ equals: true } switches the embedded validator to validateEquals semantics โ any property not declared on the parameter type becomes a validation error at runtime.
(The schema-side additionalProperties: false is always emitted on LLM parameter schemas, regardless of the flag โ the LLM is told upfront which fields are allowed either way. equals only changes how the runtime validator treats stray properties the model produced anyway.)
Same switch works on structuredOutput and controller. Leave it false for chat-style agents (more tolerant of model variability), turn it on for production tools where extra fields would be a real bug.
Custom validation
Type-based validation rejects โthis isnโt even the right shape.โ It cannot reject business-rule failures like โthis user doesnโt exist,โ โthis price exceeds the customerโs plan limit,โ โthis URL points at a host weโve banned.โ For those, hook your own validator into typia.llm.application through the runtime config.validate parameter:
import typia, { IValidation } from "typia";
const BANNED_HOSTS = ["evil.example.com"];
class BookmarkService {
/** Save a bookmark. */
async add(props: { url: string; tags: string[] }): Promise<{ id: string }> {
/* โฆ */
return { id: "โฆ" };
}
/** Delete a bookmark by id. */
async remove(props: { id: string }): Promise<{ deleted: boolean }> {
/* โฆ */
return { deleted: true };
}
}
const app = typia.llm.application<BookmarkService>({
validate: {
// Per-method custom validator. Receives the raw LLM-produced input,
// returns an IValidation<ArgumentType> that the harness threads
// through to the model on failure.
add: (input: unknown): IValidation<{ url: string; tags: string[] }> => {
// Run the default type check first.
const typed = typia.validate<{ url: string; tags: string[] }>(input);
if (!typed.success) return typed;
// Then layer business rules on top.
if (BANNED_HOSTS.includes(new URL(typed.data.url).host)) {
return {
success: false,
data: typed.data,
errors: [{
path: "$input.url",
expected: "URL not on the banned-host list",
value: typed.data.url,
}],
};
}
return typed;
},
// Methods omitted from the `validate` map keep the default type-based
// validator โ only override the ones that need extra rules.
},
});What goes in the map: one entry per method on the class. The key is the method name (typed against keyof Class, so a typo is caught at compile time). The value is (input: unknown) => IValidation<ArgumentType> โ ArgumentType is the same keyworded-object type the method signature declared, and IValidation<T> is the same discriminated union typia.validate<T> returns. Only methods with a single keyworded-object parameter qualify (the same shape rule Restrictions enforces on the class).
What happens on failure: the validatorโs IValidation.IFailure flows into the same feedback channel the type-based validator uses โ LlmJson.stringify(failure) formats your custom errors with the same // โ [...] annotations the LLM has already learned to read. So a custom business-rule failure is indistinguishable to the model from a missing required field; it just retries with corrected input. (You donโt have to format anything, choose retry logic, or decide what to send back โ the harness already does all of that.)
Want both strict-shape and business-rule checks? equals is a generic parameter, validate is the runtime config โ pass both:
typia.llm.application<BookmarkService, { equals: true }>({
validate: {
add: (input) => typia.validateEquals<โฆ>(input), // or layer rules on top
},
});Inside the hook, swap typia.validate for typia.validateEquals if you want strict-shape rejection on top of business rules. The harness no longer runs the embedded validator when a hook is registered โ you decide what runs first.
Where to go next
- Just need a schema for structured output โ
llm.structuredOutputorllm.parameters - The harness mechanics in depth โ
LlmJsonutilities - Same idea from an OpenAPI document โ
HttpLlm - Building a full chatbot on top โ Agentica
- Description, hiding, and namespacing tips โ Documentation Strategy