Skip to Content
๐Ÿ“– Guide DocumentsLLM Function Callingapplication() function

typia.llm.application โ€” turn a TypeScript class into LLM tools

Tip

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.

hello-application.ts
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

typia
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

examples/src/llm/application.ts
import typia, { ILlmApplication } from "typia"; import { BbsArticleService } from "./BbsArticleService"; const app: ILlmApplication = typia.llm.application<BbsArticleService>(); console.log(app);

๐Ÿ’ป Open in Playground

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[] }) or void. Primitives, arrays, and T | undefined are 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 โ€” use Array<{ key: string; value: number }>.
examples/src/llm/application.violation.ts
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:

Terminal
npm i @ai-sdk/openai @typia/vercel ai
src/main.ts
import { 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 ```json markdown block
  • return "42" when the schema expects a number
  • emit a double-stringified object: "\"{\\\"x\\\":1}\"" instead of { x: 1 }

The ILlmFunction typia generates carries three methods:

  1. parse(text) โ€” lenient JSON parsing with type coercion based on the schema
  2. coerce(obj) โ€” type coercion only (use when the SDK already JSON-parsed for you)
  3. validate(obj) โ€” full schema validation; returns IValidation<unknown>

Plus a fourth helper that lives on @typia/utils because it doesnโ€™t depend on a specific functionโ€™s schema:

  1. 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, retry

See 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

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 }; }

Which one do you call โ€” parse or coerce?

  • LLM gives you a raw string (OpenAI chat.completions content, etc.) โ†’ call parse(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 below
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"}] } } }

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.

AutoBeTest.IExpression โ€” how hard a type the harness solves
// 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 total

Strict 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.)

Tip

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

Last updated on