
こんにちは。RightTouchでエンジニアをしている追木です。
先日、tskaigi2026にて下記の題目で登壇しました。本記事では、発表内容を振り返りつつ、その中で語りきれなかったTypiaの仕組みについて紹介します。
以前書いた下記の記事もぜひ読んでみてください。 tech.righttouch.co.jp
Typiaが何をしているか
Typiaがやっていることを1行でまとめると、次のようになります。
typia.validate<T>(input)という呼び出しの式そのものを、コンパイル時に、その型専用に書き起こされた検証コード にまるごと差し替える
もう少し具体的には、次のようなBefore / Afterが起きています。
Before:書いたコード
const result = typia.validate<{ id: number; name: string }>(input);
After:コンパイル後のJS(イメージ)
const result = ((input) => { const errors = []; if (typeof input !== "object" || input === null) errors.push({ path: "$input", expected: "object", value: input }); else { if (typeof input.id !== "number") errors.push({ path: "$input.id", expected: "number", value: input.id }); if (typeof input.name !== "string") errors.push({ path: "$input.name", expected: "string", value: input.name }); } return errors.length ? { success: false, errors, data: input } : { success: true, errors: [], data: input }; })(input);
実行時には typia.validate という関数はコード上にはなく、代わりに「その型専用の関数を即時呼び出ししている式」に置き換えられます。
また、 zod などは実行時にスキーマを組み立てる発想なので、z.object({ id: z.number() }) のように 同じ型情報を二度書く ことになります。一方Typiaは型情報をそのままコードへ展開できるため、このコストがかかりません。
Typia の処理の流れ
では、Typiaの中では何が起きているのでしょうか。typia.validate<T>(input) に対応するコード生成までは、4ステップに分解できます。
①「これはTypiaの呼び出しだ」と気づく
↓
②「<T> の型は何か」をTypeScript Compilerに聞く
↓
③ その型を独自の中間表現(Metadata)に書き写す
↓
④ Metadataから検証コードを組み立て、元の式と差し替える
①〜②は、TypeScript Compilerへ割り込んで行う処理です。ファイル中の関数呼び出しを巡回して typia 由来のものを見つけ、<T> の部分を TypeChecker に問い合わせて型情報(ts.Type)を取り出します。なお、素の tsc にはこうした変換器を差し込む口がないため、ts-patch のようなツールでフックを噛ませる必要があります。本記事では発表と同様に、続く ③ と ④ を詳しく見ていきます。
③ なぜ独自の中間表現に書き写すのか
Typiaは取り出した ts.Type を、そのままコード生成には使いません。代わりに Metadata という独自のデータ構造に書き写してから、コード生成へ進みます。
ts.Type │ MetadataFactory.analyze で書き写す ▼ Metadata(Typia独自の中間表現) │ コード生成に使う ▼ TypeScript の AST
Typiaはvalidatorをはじめ、さまざまなコードを生成します。その各所で ts.Type を直接扱おうとすると、コード生成が煩雑になります。属性の取得が TypeChecker 越しになり、null や ? の扱いも分散しているうえ、再帰の検出も自前で持ち回す必要があるためです。そこで 前段で一度だけ正規化しておく ことで、後段のコード生成をシンプルに保っています。
Metadata は、どんな型でも同じ形のオブジェクト1個 に押し込めたデータ構造です。たとえば { id: number; name: string } であれば、次のような形になります。
{ objects: [{ name: "User", recursive: false, properties: [ { key: "id", value: { atomics: ["number"] } }, { key: "name", value: { atomics: ["string"] } }, ], }] }
union型でも特別なプロパティは登場せず、atomics や objects の配列に複数の値を入れるだけです。null や optional も独立したフラグになっています。これによってコード生成側は、素直なオブジェクト操作だけで済みます。
もちろん複雑さが消えるわけではなく、MetadataFactory の1箇所に閉じ込めているだけです。とはいえTypiaでは、validatorのほかにJSONシリアライザ・LLMスキーマ生成・ランダム値生成などが同じMetadataを共有しています。一度正規化しておけば、これらの生成器がその成果を共通して使える設計です。
④ Metadataから検証コードを生成する
あとは Metadata を見て、TypeScriptのコードを組み立てるだけです。中心となる関数 decode が、Metadataのフィールドを順番に舐めながら、対応するチェック式に変換していきます。
// 実際のコードはもっと複雑ですが、概念的にはこんな感じです。 function decode(input, meta) { const exprs = []; if (meta.nullable) exprs.push(eq(input, NULL)); for (const a of meta.atomics) exprs.push(typeofEq(input, a.type)); for (const arr of meta.arrays) exprs.push(arrayCheck(arr, input)); for (const obj of meta.objects) exprs.push(objectCheck(obj, input)); // ... return reduceOr(exprs); }
ネストした型には、decode が 自分自身を再帰呼び出し することで対応します。これだけで、冒頭で見せた検証コードへ展開されます。
再帰型ではどうなるか
では、より複雑な形ではどうなるのでしょうか?decode は普通の型であれば素直にインライン展開しますが、例えば再帰型に出会うと少し違う動きをします。
interface Tree { name: string; children: Tree[]; }
Step ③ での再帰検出
MetadataFactory は型を辿りながら、出会った型を MetadataCollection という型キャッシュへ登録していきます。Tree を辿ると children の中で 再び Tree に出会うため、登録済みのインスタンスが見つかるという流れです。
このときにやることは、次の2つです。
- 既存のインスタンスをそのまま参照として返す
recursive: trueフラグを立てる
「同じ型に二度到達したら再帰とみなす」という単純なルールで、再帰の検出と無限ループの回避を両立しています。
Step ④ での生成戦略:インライン展開 vs 関数化
decode は配列やオブジェクトに出会うたび、このフラグを参照して生成戦略を切り替えます。
| 型 | 非再帰(recursive=false) | 再帰(recursive=true) |
|---|---|---|
| 配列・タプル | その場でインライン展開 | ローカル関数として閉じて呼び出す |
| オブジェクト | 基本的に関数化 | 関数化 |
オブジェクトを 基本的に関数化 するのは、インライン展開するとコードが膨らみやすいからです。プロパティ数が多くなりやすく、同じ型を複数箇所から参照することもあるためです。
Tree の場合、最終的に次のような形のコードが生成されます。
((input) => { // Tree 用のローカル関数 const __checkTree = (value) => typeof value === "object" && value !== null && typeof value.name === "string" && __checkChildren(value.children); // ← 再帰参照は関数呼び出しに // Tree[] 用のローカル関数 const __checkChildren = (value) => Array.isArray(value) && value.every(elem => __checkTree(elem)); return __checkTree(input) ? { success: true, data: input, errors: [] } : /* エラー収集パスへ */; })(input);
__checkTree と __checkChildren が 互いを呼び合うクロージャ になっていて、これがループとして機能します。仮に再帰型もインライン展開しようとすると、コンパイル時に生成するAST自体が無限に膨らんでしまう ので、どこかの段階で関数として閉じるしかありません。これが関数化に分岐する動機です。
どれだけ複雑な再帰型でも、最終的にはローカル関数の組に落ちます。そのためコンパイル時に生成するコードが無限に膨らむことはなく、実行時も通常の関数呼び出しとして動きます。
union と discriminator
例として、kind で種類を区別する数式ASTを考えます。リテラル・二項・単項の3つからなるunionで、再帰も含みます。
type Literal<V> = { kind: "literal"; value: V }; type BinaryExpr<V> = { kind: "binary"; op: "+" | "-" | "*" | "/"; left: Expression<V>; right: Expression<V> }; type UnaryExpr<V> = { kind: "unary"; op: "neg" | "abs"; operand: Expression<V> }; type Expression<V> = Literal<V> | BinaryExpr<V> | UnaryExpr<V>;
unionの検証には、メンバを順番に試して合致するものを探す方式もあります。しかしこの方式は、どこで失敗したのか分かりにくく、枝が増えるほど検証コストもかさみます。
Typiaは、unionメンバを判別できる場合(上のような kind のリテラルdiscriminatorや、必須プロパティの有無など)には、それを手がかりに O(1) で枝分かれするコード を生成します。
const __checkExpr = (value) => { if (value.kind === "literal") return /* Literal の判定 */; if (value.kind === "binary") return /* BinaryExpr の判定(再帰) */; if (value.kind === "unary") return /* UnaryExpr の判定(再帰) */; return false; };
こうすることで、誤検出も起きにくく、エラーパスも正確に拾えます。
fast path / slow path の二段構え
typia.validate<T>() から生成されるコードの中には、fast pathとslow pathの2つのロジックがあります。
- fast path:booleanだけを返す軽量な判定(
typia.is<T>()相当) - slow path:
expected/pathを詰めたエラーを集める詳細な検証
実行時はまずfast pathで走り、通ればその場で終了します。通らなかった場合にのみ、slow pathへ切り替えます。正常系がほとんどを占める現実のユースケースでは、重い処理が失敗時にしか走らない ため、ランタイムコストを小さく抑えられます。
まとめ
- Typiaは コンパイル時に
typia.validate<T>(input)を、その型専用の検証コードへまるごと差し替える ts.Typeを 独自の中間表現(Metadata)へ正規化 することで、ジェネリックや再帰を含む複雑な型でも素直に扱える- 再帰は 「同じ型に二度到達したらフラグを立てる」 という単純なルールで検出され、互いを呼び合うクロージャとして関数化される
- unionは discriminatorによる O(1) 分岐、検証は fast / slow の二段構え で、正確性とランタイムコストを両立
ブログや発表で「再帰や複雑にネストされた型でも特に工夫なく動く」と紹介した裏側は、このようなシンプルな仕組みの積み重ねで支えられていました。型ファーストなランタイム検証を検討する際の、選定の参考になればうれしいです。
採用情報
RightTouchでは、Product Engineerをはじめ、一緒に最高のプロダクトを作り、ユーザーに届ける 仲間を積極採用中です!
カジュアル面談も歓迎しています。ご興味があれば、ぜひ採用ページをご覧ください。