📦

TypeScriptの型システムに命を吹き込む: Typia と unplugin-typia

2024/06/13に公開

TL;DR

  • この度、unplugin-typia という Library を作りました
  • unplugin-typia を使うと今までめんどくさかった Typia の導入が簡単になります
  • Viteesbuildwebpackなどフロントエンドで主流の様々なbundlerに対応しています
  • Next.jsでも簡単に使えます
  • Bunにも対応しています

https://github.com/ryoppippi/unplugin-typia/
https://jsr.io/@ryoppippi/unplugin-typia
https://typia.io/docs/setup/#unplugin-typia

はじめに

皆さんはTypeScriptでのValidationにはどのような Library を使っていますか?

zodはエコシステムが硬いし、最近だとvalibotが流行りつつありますね。
またarktypeも注目に値するLibraryです。
typeboxも耳にする機会が増えてきました。
また個人的には(厳密にはValidatorではないですが)、unknownutilも手に馴染んでよく使っています。

既存のValidation Library/TypeScriptに足りないもの

TypeScriptの型システムは非常に強力です。
アップデートを重ねた結果とても豊かな表現力を持ち、型システムとしてチューリング完全であることが知られています[1]
型パズルを駆使すればbrainf**k interpreter[2]でさえ書けてしまいます。

しかし、型システムは実行時には消えてしまいます。また、TypeScriptでは原則として「値から型を作る」ことはできますが、「型から値を作る」ことはできません。

型から値への変換の限界

TypeScriptの型情報は開発時の安全性を提供しますが、実行時に動作することはありません。
例えば、以下のようなコードを考えます:

function someFunction(): any {}
const value: string = someFunction();

このコードではsomeFunctionの戻り値がstring型であることを保証するためにtype assertionや明示的なValidationが必要ですが、型自体からそのようなValidation logicを生成することはできません。

zodvalibotなどの既存のValidation Libraryは、TSの Library として用意されたDSL(ドメイン固有言語)で assertion を定義し、そこからTypeScriptの型を生成(推論)することで型安全を担保しています。
これでは既存の型に対する Validation 関数を素朴な方法で用意できず、DSLを覚えて、一から Validation 関数を作りなおす必要があります。
このため型とValidation Logicが分離してしまうことがあります。
また、TSという型システムがあるのに、さらにDSLで型システムに準じるものを作り直すのは直感的ではないですよね。

Typia とは

https://typia.io/

Typiaは、このような課題を解決するためのツールです。

  • 高速: Typiaは既存のValidation Library に比べて非常に高速です。「zodの1500倍速い」とも言われています[3]
  • 型情報から Validation を生成: TypeScriptの型情報を元に Validation を生成します。コンパイルが必要ですが、これにより型チェックの正確性とパフォーマンスが向上します。
  • シンプルな記法: 特定の Library 特有の記法を覚える必要はなく、TypeScriptの標準的な型記述から Validation を生成します。
  • 多機能: Validation だけでなく、高速なJSON変換、JSON Schema生成、ProtoBuf生成、ランダムデータ生成などの機能も提供します。

Typiaの高速さは特筆すべき点であり、実際に使用したプロジェクトでは、APIドキュメントから自動生成された大量のTypeScript型ファイルを使って Validation を行う場面で非常に有効でした。

しかし、Typiaの真の強みは、「独自の記法を覚える必要がないこと」 にあります。
この点は既存の Validation Library との大きな差別化要因であり、Typiaの導入障壁を大幅に下げています。
Typiaを使えば、TypeScriptの標準的な記述に従うだけで、自然にValidationや他の機能を実現できるため、新しいLibaryに合わせてルールやシンタックスを覚える必要がありません。
開発者は既存のTypeScriptの知識だけでTypiaを使いこなすことができるのです。

Typiaは実行時に消え去る運命に合った型情報に息を吹き込む Library と言えるでしょう。

Typia のコードを見てみよう

シンプルな例

手始めに簡単な例から:

import typia from "typia";

const b = typia.is<string>('hello world')
console.log(b)

このコードは、'hello world'string型であるかどうかをチェックするコードです。
これをTypiaでコンパイルすると以下のようなコードが生成されます。

// 生成されたコード
import typia from "typia"; 
const b = ((input: any): input is string => {
  return "string" === typeof input;
})("hello world");
console.log(b); // true

このように、typia.is型情報から Validation関数を生成します。
生成されたコードを見てみると、importされているtypiaはどこからも参照されていないので、このあとbundlerを挟むとtree-shakingされることがわかります。

Object型の例

次は、一般的な型に対してValidationを行うコードを見てみましょう。
例えば、以下のようなMember型があり、それをチェックするコードがあるとします。
ここではValidationを行う関数を生成するためにtypia.isを使っています。

// 元のコード
import typia from "typia";

interface Member {
  /**
  * @format uuid
  */
  id: string ;

  /**
  * @type uint32
  * @minimum 20
  * @exclusiveMaximum 100
  */
  age: number;

  name: string;

  time?: Date;
}

const member = { id: '', name: 'taro', age: 20 } as const satisfies Member;
console.log(typia.is<Member>(member)) // false

これをTypiaを使ってコンパイルするとランタイムでの型チェックを行うコードが生成されます。
少し長いので折りたたんでいます。

生成されたコード
// Typiaによって生成されたコード
import typia from "typia"; // ←もはや`typia`は使用されてないのでtree-shakingの対象になる
interface Member {
  /**
   * @format uuid
   */
  id: string;
  /**
   * @type uint32
   * @minimum 20
   * @exclusiveMaximum 100
   */
  age: number;
  name: string;
  time?: Date;
}
const member = { id: "", name: "taro", age: 20 } as const satisfies Member;
console.log(
  ((input: any): input is Member => {
    const $io0 = (input: any): boolean =>
      "string" === typeof input.id &&
      /^(?:urn:uuid:)?[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(
        input.id,
      ) &&
      "number" === typeof input.age &&
      Math.floor(input.age) === input.age &&
      0 <= input.age &&
      input.age <=
        /**
         * @format uuid
         */ 4294967295 &&
      20 <= input.age &&
      input.age < 100 &&
      "string" === typeof input.name &&
      (undefined === input.time || input.time instanceof Date);
    return "object" === typeof input && null !== input && $io0(input);
  })(member),
); // false

またtypiaにはJSDocの代わりにtagを用いる別の書き方もあります。
生成されるコードは同じですが、Editor上で補完が効くので便利です。

import typia, { tags } from "typia";

interface Member {
  id: string & tags.Format<"uuid">;
  name: string;
  time?: Date;
  age: number &
    tags.Type<"uint32"> &
    tags.Minimum<20> &
    tags.ExclusiveMaximum<100>;
}

const member = { id: '', name: 'taro', age: 20 } as const satisfies Member;
console.log(typia.is<Member>(member)) // false

型関数を用いたより複雑な型に対してもValidation関数を生成できます。

比較的複雑な型の例
// 元のコード
import typia from "typia";

type D = {
  /**
  * @format uuid
  */
  id: string ;

  age?: number | null
}

interface Member {
  name: string;
  id: string;
  details: D;
}

type ValidateType = Pick<Member, 'details'> & Omit<Member, 'id'>;

console.log(typia.is<ValidateType>({} as unknown)) // false
// 生成されたコード
type D = {
  /**
   * @format uuid
   */
  id: string;
  age?: number | null;
};
interface Member {
  name: string;
  id: string;
  details: D;
}
type ValidateType = Pick<Member, "details"> & Omit<Member, "id">;
console.log(
  ((input: any): input is ValidateType => {
    const $io0 = (input: any): boolean =>
      "object" === typeof input.details &&
      null !== input.details &&
      $io1(input.details) &&
      "string" === typeof input.name;
    const $io1 = (input: any): boolean =>
      "string" === typeof input.id &&
      /^(?:urn:uuid:)?[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(
        input.id,
      ) &&
      (null === input.age ||
        undefined === input.age ||
        "number" === typeof input.age);
    return "object" === typeof input && null !== input && $io0(input);
  })({} as unknown),
); // false

TypiaのDocumentにはPlaygroundが用意されているので、実際に試してみるといいでしょう。
https://typia.io/playground/

詳細なベンチマーク結果はこちらの記事を参照してください。
https://zenn.dev/hr20k_/articles/3ecde4239668b2

Typiaの導入の課題

これまでTypiaを使おうとすると、いくつかのハードルが必要でした。

Typiaには2つのモードがあります。TransformationモードとGenerationモードです。

  • Transformationモード: tscのTransfrom APIを使って型情報からValidationを生成するモード。tsc実行時にValidationのコードが生成される。
  • Generationモード: TypiaのCLIを使って型情報からValidationを生成するモード。Bundlerがtscを使わない場合に使う。

癖がなくハマりずらいのはGenerationモードです。
TypiaのCLIを使ってコードを生成し、それを他のファイルからimportするだけで使うことができます。
しかし、ビルドステップが一つ増えますし、管理するコードも増えるのでできればTransformationモードを使いたいですよね。

ところがTransformationモードは導入のハードルが高いです。

直接tscコマンドを叩いてコンパイルする場合は導入が簡単ですが、他のBunlderを使う場合にはあらかじめtscを経由してBundleするように設定をする必要があります。

viteの例
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import typescript from "rollup-plugin-typescript2";
 
// https://vitejs.dev/config/
export default defineConfig({
  esbuild: false,
  plugins: [
    react(),
    typescript(),
  ],
});

ただ手元だとうまく動かないことも多かったです。特にts/tsx以外のhtml-ishな言語[4]、例えばsveltevueなどを使用しているプロジェクトでは特に問題が多かったです。

https://github.com/samchon/typia/issues/812

そのため Webpack などのBundlerを用いたプロジェクト( Next.js など )では、tscを使ってコンパイルをしていないため、Generationモードを使う必要がありました。

Generationモードを使ってもいいのですが、まあビルドステップが一つ増えますし、管理するコードも増えるので、できればTransformationモードを使いたいですよね。

unplugin-typia

そこで、私はunplugin-typia を作りました。

https://github.com/ryoppippi/unplugin-typia
https://jsr.io/@ryoppippi/unplugin-typia

unplugin-typiaunplugintscを組み合わせて作っています。
unplugin とは、Viteesbuildwebpack などの複数のbundlerに対応したプラグインを共通のAPIで作るためのLibraryです。

unplugin-typiaを使うと、複雑な設定をすることなく、bundlerでtypiaのTransfrom モード相当の体験を得ることができます。

実際にunplugin-typiaを使ってみよう

では簡単にunplugin-typiaTypiaを使って遊んでみましょう。

Bun と一緒に使ってみる

一番手っ取り早く使う方法はBun.buildを使うことです。

Github上にテンプレートを作成したので、それを使ってプロジェクトを作成します。

https://github.com/ryoppippi/bun-typia-template

git clone https://github.com/ryoppippi/bun-typia-template
cd bun-typia-template
bun i

# 以下のコマンドで実行
bun run index.ts

# もしビルドして実行したい場合
bun run build # build.tsを実行してビルドを行う。`./out`にビルドされたファイルが出力される
bun run ./out/index.js 
# もしくは
node ./out/index.js

これだけで、Typiaを使ったコードを実行することができます。

とっても簡単ですね。

Vite + Hono + unplugin-typiaで使ってみる

次に、ViteHonounplugin-typiaを使って遊んでみましょう。

プロジェクトの作成

まずは、プロジェクトを作成します。

npm create hono@latest ./my-app -- --template cloudflare-pages
cd my-app
npm install

次に、unplugin-typiaをインストールします。
unplugin-typiaJSRに公開されているので、jsr コマンドを使ってインストールします。

npx jsr add -D @ryoppippi/unplugin-typia

そしてtypiaを導入します。Typiaのドキュメントを参考にしてください。

npm i typia # typiaをインストール
npx typia setup # typiaのsetup wizardを実行
npm i @hono/typia-validator --force # honoのtypia-validatorをインストール

これで準備ができましたね!

unplugin-typiaの設定をする

unplugin-typiaviteのプラグインとして使います。
vite.config.tsに以下のように設定します。

 import build from '@hono/vite-cloudflare-pages'
 import devServer from '@hono/vite-dev-server'
 import adapter from '@hono/vite-dev-server/cloudflare'
 import { defineConfig } from 'vite'
+import UnpluginTypia from '@ryoppippi/unplugin-typia/vite';
 
 export default defineConfig({
   plugins: [
     build(),
+    UnpluginTypia(), // unplugin-typiaを追加
     devServer({
       adapter,
       entry: 'src/index.tsx'
     })
   ]
 })

Typiaを使って実装してみる

では、src/index.tsxに以下のコードを書いてみましょう。

 import { Hono } from 'hono'
 import { renderer } from './renderer'
+import typia from 'typia'
+import { typiaValidator } from '@hono/typia-validator'
 
+interface Props {
+  name: string
+}
 
 const app = new Hono()
 
 app.use(renderer)
 
 app.get('/', (c) => {
   return c.render(<h1>Hello!</h1>)
 })
 
+app.post('/',
+  typiaValidator('json', typia.createValidate<Props>()),
+  (c) => {
+    const data = c.req.valid('json');
+
+    return c.json({
+      success: true,
+      message: `Hello ${data.name}!`
+    })
+  }
+)
 
 export default app

これで準備ができました。

実行してみる

それでは、実行してみましょう。

$ npm run dev

> dev
> vite

 ╭──────────────────────────────────╮
 │                                  │
 │  [unplugin-typia] Cache enabled  │
 │                                  │
 ╰──────────────────────────────────╯

(!) Could not auto-determine entry point from rollupOptions or html files and there are no explicit optimizeDeps.include patterns. Skipping dependency pre-bundling.

  VITE v5.2.13  ready in 588 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose
  ➜  press h + enter to show help

無事、起動できましたね!

それでは、APIを叩いてみましょう。

curlを使ってもいいのですが記述が長くなるので、ここではxhを使ってみましょう。

$ xh :5173 name=ryoppippi
HTTP/1.1 200 OK
Connection: keep-alive
Content-Length: 45
Content-Type: application/json; charset=UTF-8
Date: Wed, 12 Jun 2024 11:14:19 GMT
Keep-Alive: timeout=5

{
    "success": true,
    "message": "Hello ryoppippi!"
}

無事、Validationが通り、APIが叩けましたね!

試しにnameを文字列ではなく数値で送ってみましょう。

$ xh :5173 name:=5
HTTP/1.1 400 Bad Request
Connection: keep-alive
Content-Length: 80
Content-Type: application/json; charset=UTF-8
Date: Wed, 12 Jun 2024 11:15:58 GMT
Keep-Alive: timeout=5

{
    "success": false,
    "error": [
        {
            "path": "$input.name",
            "expected": "string",
            "value": 5
        }
    ]
}

Validation Error が返ってきましたね!

Cloudflare Pagesにデプロイしてみる

最後に、Cloudflare Pagesにデプロイしてみましょう。

$ npm run deploy
$ $npm_execpath run build && wrangler pages deploy
$ vite build

 ╭──────────────────────────────────╮
 │                                  │
 │  [unplugin-typia] Cache enabled  │
 │                                  │
 ╰──────────────────────────────────╯

vite v5.2.13 building SSR bundle for production...
✓ 50 modules transformed.
dist/_worker.js  67.14 kB
✓ built in 167ms                                                                                                                                                                                                          The project you specified does not exist: "hono-vite". Would you like to create it?"
❯ Create a new project
✔ Enter the production branch name: … main
✨ Successfully created the 'hono-vite' project.
🌏  Uploading... (1/1)

✨ Success! Uploaded 1 files (1.61 sec)

✨ Compiled Worker successfully
✨ Uploading Worker bundle
✨ Uploading _routes.json
🌎 Deploying...
✨ Deployment complete! Take a peek over at https://xxx.pages.dev

無事デプロイできましたね!
あとはこのURLを実際に呼んでみましょう。

$ xh https://xxx.pages.dev name=ryoppippi

# 省略

{
    "success": true,
    "message": "Hello ryoppippi!"
}

無事デプロイされたAPIが叩けましたね!

まとめ

  • unplugin-typiaを使うとTypiaの導入が簡単になります
  • Viteesbuildwebpackなどのbundlerに対応しています
  • Typiaは楽しい
  • Typiaは面白い!

ぜひTypiaを試してみてください!

Appendix

jsonup + typia

jsonupはTypeScriptで書かれたJSON Parserです。
JSON形式のLiteral Stringを与えると型推論を行うという、謎技術 Library です(本人曰くネタで作ったそうですが、TypeScriptのCompilerの限界に挑戦してる感が好きです)。

https://github.com/tani/jsonup

そして、jsonuptypiaを組み合わせるとなんと、文字列からvalidation関数が生成されるという、なんとも不思議なことができます。

https://x.com/ryoppippi/status/1800183030235255019

typiaにはrandom generatorがついているので、JSONの文字列を例として与えるとそれに合致するランダムな値を生成する、なんてこともできます。

import typia from "typia"
import type { ObjectLike } from 'jsonup'

const jsonSample = `{ "name": "jsonup", "age": 34}`;

/**
* type Obj = {
*    name: string;
*    age: number;
* }
*/
type Obj = ObjectLike<typeof jsonSample>

console.log(typia.random<Obj>())

$ bun run ./index.ts
{ name: 'dvdnp', age: 49.25475568792122 }
$ bun run ./index.ts
{ name: 'htgywkaq', age: 13.818270173692525 }
$ bun run ./index.ts
{ name: 'pyurujgkvd', age: 47.19975642889989 }

https://github.com/ryoppippi/bun-typia-jsonup-experiments

GitHubからコードを落とせるのでぜひ遊んでみてください。

自分はjsonuptype-festのような型パズルの Library が大好きなので、これらに息を吹き込めるようなtypiaはとても楽しいです。

typefuck + typia

typefuck とは、型レベルで実装されたBrainfuck interpreterです。

https://github.com/susisu/typefuck

この実装により、TypeScriptの型システムはチューリング完全であることがわかるのですが、こちらもTypiaと組み合わせることができます。

import typia from "typia";
import type { Brainfuck } from "@susisu/typefuck";

type Program = ">, [>, I<[.<]";
type Input = "Hello, world!";
type Output = Brainfuck<Program, Input>;

console.log(typia.is<Output>("!drow ,olleH")) // true

type-fest + typia

type-festとは、TypeScriptで型を操作する時の便利関数を集めたLibraryです。

https://github.com/sindresorhus/type-fest

たとえば、xor[5]を実現するための型としてMergeExclusiveが用意されています。
これをTypiaと組み合わせると、それぞれのkeyに対して排他的なObject型ができあがります。

import typia from "typia"
import type { SimplifyDeep, MergeExclusive } from 'type-fest';

interface ExclusiveVariation1 {
	exclusive1: boolean;
}

interface ExclusiveVariation2 {
	exclusive2: string;
}

type ExclusiveOptions = SimplifyDeep<
  MergeExclusive<
    ExclusiveVariation1, 
    ExclusiveVariation2
    >
>

const is = typia.createIs<ExclusiveOptions>()

console.log(is({exclusive1: true})) // true
console.log(is({exclusive2: "string"})) // true
console.log(is({exclusive1: true, exclusive2: "string"})) // false
ExclusiveOptionsの型
type ExclusiveOptions = {
    exclusive1?: undefined;
    exclusive2: string;
} | {
    exclusive2?: undefined;
    exclusive1: boolean;
}

また、IntRangeという型もあります。この型は指定された範囲の整数を表します。
これを使って、1ケタの数字を表す型を作り、Validation関数を生成してみましょう。
さらに、random関数を使ってランダムな値を生成してみます。

import typia from "typia"
import type { IntRange } from 'type-fest';

type Digit = IntRange<0, 10> // 0 <= Digit < 10

const is = typia.createIs<Digit>();

console.log(is(5)); // true
console.log(is(11)); // false
console.log(is(-1)); // false

console.log(typia.random<Digit>()); // 5 (or any other number between 0 and 9)
random関数のコンパイル結果
console.log(((generator) => {
  const $pick = typia.random.pick;
  return $pick([
    () => 0,
    () => 1,
    () => 2,
    () => 3,
    () => 4,
    () => 5,
    () => 6,
    () => 7,
    () => 8,
    () => 9
  ])();
})());

自分の知る限り、従来型のValidation Libraryでこのようなロジックを組んだとしても、それが型(z.infer<foo>など)に反映されることはないと思います。
たとえばzodであれば

import { z } from 'zod'
import { IntRange } from 'type-fest'
const digit = z.number().int().min(0).max(10).transform((x) => (x as IntRange<0, 10>));

とする必要がありますが、これは型とValidation Logicが分離してしまっていますよね。

Typiaは型情報からValidation Logicを生成するため、型とValidation Logicが一体となっているのが特徴です。

zod/valibotとバンドルサイズの比較

先ほどのTypiaでの実装例zodvalibotで書いた例と比較してみましょう。

import { z } from 'zod'

const Member = z.object({
  id: z.string().uuid(),
  age: z.number().int().min(20).max(99),
  name: z.string(),
  time: z.date().optional(),
})

type Member = z.infer<typeof Member>

const member = { id: "", name: "taro", age: 20 } as const satisfies Member;

console.log(Member.parse(member))
import * as v from 'valibot'

const Member = v.object({
  id: v.pipe(v.string(), v.uuid()),
  age: v.pipe(
    v.number(),
    v.integer(),
    v.minValue(20),
    v.maxValue(99) // exclusiveの代わりに-1しておく
  ),
  name: v.string(),
  time: v.optional(v.date()),
})

type Member = v.InferOutput<typeof Member>

const member = { id: "", name: "taro", age: 20 } as const satisfies Member;

console.log(v.is(Member, member))
Library バンドルサイズ
typia 0.563 kb
zod 117 kb
valibot 8.50kb

とても小さいですね!

bundle sizeについて補足

現在の実装でもcreateIsという型をチェックするだけの関数を生成する場合はバンドルサイズがとても小さいです。
しかし、エラーメッセージを生成するための関数を生成しようとすると、なぜかそこにRandom Generatorを含めてしまいバンドルサイズが大きくなってしまうようです。
おそらくzodと同じように Library のかなりの部分を巻き込んでバンドルされているようです。
それでもzodよりは全然小さいですが...
(余談ですが、 zodも次のバージョンでバンドルサイズを削減する予定です )

これについては Typia は現在内部のリファクタリングを進めています。

また、先日リリースされたv6.1.0では、本格的に Typia が ESM に対応しました。
これにより、Validationの関数のバンドルサイズは手元だと2/3ほどになりました。

https://github.com/samchon/typia/releases/tag/v6.1.0

開発環境について

ひとりごと

今回の unplugin-typianpm ではなく JSR で公開されています。
JSRnpm と同じようにパッケージを公開できるサービスですが、npm とは異なり、サーバー上で package.json の生成や TypeScript のコンパイルを開発者の代わりに行ってくれるので、 Libaray の公開がとても簡単です。
欠点としてESMにしか対応していないので、例えば Webpack の設定を書くときは直接 require するとエラーになってしまいますが、jiti などを経由すれば問題ありません。
実際、Webpackの導入方法 では jiti を使った方法を紹介しています。

またローカルでは Bun を使って開発しています。
Bun は TypeScript をそのままimport、実行できるのでとても楽でした。

結果的に bundler 向けの plugin を作っているのに自分は一切 bundler を使わない、という謎な状況になっていますが、開発体験はめちゃくちゃ良かったです。

まあunplugin-typia のコードベースが Bun である必要も特にないので、node_modules を管理しなくて済む Deno に移行するかもしれません。
Deno も直接 TypeScript を実行できますし、何より JSR との相性は抜群ですからね。

リンクなど

脚注
  1. https://github.com/Microsoft/TypeScript/issues/14833 ↩︎

  2. https://github.com/susisu/typefuck ↩︎

  3. https://zenn.dev/hr20k_/articles/3ecde4239668b2#速度の比較 ↩︎

  4. https://biomejs.dev/blog/biome-v1-6/#partial-support-for-astro-svelte-and-vue-files ↩︎

  5. https://ja.wikipedia.org/wiki/排他的論理和 ↩︎

Discussion