Typiaは複雑な型をどう扱っているのか?

Typiaは複雑な型をどう扱っているのか?

こんにちは。RightTouchでエンジニアをしている追木です。

先日、tskaigi2026にて下記の題目で登壇しました。本記事では、発表内容を振り返りつつ、その中で語りきれなかったTypiaの仕組みについて紹介します。

speakerdeck.com

以前書いた下記の記事もぜひ読んでみてください。 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型でも特別なプロパティは登場せず、atomicsobjects の配列に複数の値を入れるだけです。nulloptional も独立したフラグになっています。これによってコード生成側は、素直なオブジェクト操作だけで済みます。

もちろん複雑さが消えるわけではなく、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 pathexpected / path を詰めたエラーを集める詳細な検証

実行時はまずfast pathで走り、通ればその場で終了します。通らなかった場合にのみ、slow pathへ切り替えます。正常系がほとんどを占める現実のユースケースでは、重い処理が失敗時にしか走らない ため、ランタイムコストを小さく抑えられます。

まとめ

  • Typiaは コンパイル時に typia.validate<T>(input) を、その型専用の検証コードへまるごと差し替える
  • ts.Type独自の中間表現(Metadata)へ正規化 することで、ジェネリックや再帰を含む複雑な型でも素直に扱える
  • 再帰は 「同じ型に二度到達したらフラグを立てる」 という単純なルールで検出され、互いを呼び合うクロージャとして関数化される
  • unionは discriminatorによる O(1) 分岐、検証は fast / slow の二段構え で、正確性とランタイムコストを両立

ブログや発表で「再帰や複雑にネストされた型でも特に工夫なく動く」と紹介した裏側は、このようなシンプルな仕組みの積み重ねで支えられていました。型ファーストなランタイム検証を検討する際の、選定の参考になればうれしいです。

採用情報

RightTouchでは、Product Engineerをはじめ、一緒に最高のプロダクトを作り、ユーザーに届ける 仲間を積極採用中です!

カジュアル面談も歓迎しています。ご興味があれば、ぜひ採用ページをご覧ください。