TypeScriptの型システムに命を吹き込む: Typia と unplugin-typia
TL;DR
- この度、
unplugin-typia
という Library を作りました -
unplugin-typia
を使うと今までめんどくさかったTypia
の導入が簡単になります -
Vite
、esbuild
、webpack
などフロントエンドで主流の様々なbundlerに対応しています -
Next.js
でも簡単に使えます -
Bun
にも対応しています
はじめに
皆さんは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を生成することはできません。
zod
やvalibot
などの既存のValidation Libraryは、TSの Library として用意されたDSL(ドメイン固有言語)で assertion を定義し、そこからTypeScriptの型を生成(推論)することで型安全を担保しています。
これでは既存の型に対する Validation 関数を素朴な方法で用意できず、DSLを覚えて、一から Validation 関数を作りなおす必要があります。
このため型とValidation Logicが分離してしまうことがあります。
また、TSという型システムがあるのに、さらにDSLで型システムに準じるものを作り直すのは直感的ではないですよね。
Typia
とは
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が用意されているので、実際に試してみるといいでしょう。
詳細なベンチマーク結果はこちらの記事を参照してください。
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]、例えばsvelte
やvue
などを使用しているプロジェクトでは特に問題が多かったです。
そのため Webpack
などのBundlerを用いたプロジェクト( Next.js
など )では、tsc
を使ってコンパイルをしていないため、Generationモードを使う必要がありました。
Generationモードを使ってもいいのですが、まあビルドステップが一つ増えますし、管理するコードも増えるので、できればTransformationモードを使いたいですよね。
unplugin-typia
そこで、私はunplugin-typia
を作りました。
unplugin-typia
は unplugin
とtsc
を組み合わせて作っています。
unplugin
とは、Vite
や esbuild
、webpack
などの複数のbundlerに対応したプラグインを共通のAPIで作るためのLibraryです。
unplugin-typia
を使うと、複雑な設定をすることなく、bundlerでtypiaのTransfrom モード相当の体験を得ることができます。
unplugin-typia
を使ってみよう
実際にでは簡単にunplugin-typia
とTypia
を使って遊んでみましょう。
Bun
と一緒に使ってみる
一番手っ取り早く使う方法はBun.build
を使うことです。
Github上にテンプレートを作成したので、それを使ってプロジェクトを作成します。
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
で使ってみる
次に、Vite
とHono
とunplugin-typia
を使って遊んでみましょう。
プロジェクトの作成
まずは、プロジェクトを作成します。
npm create hono@latest ./my-app -- --template cloudflare-pages
cd my-app
npm install
次に、unplugin-typia
をインストールします。
unplugin-typia
はJSR
に公開されているので、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-typia
はvite
のプラグインとして使います。
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
の導入が簡単になります -
Vite
、esbuild
、webpack
などのbundlerに対応しています -
Typia
は楽しい -
Typia
は面白い!
ぜひTypia
を試してみてください!
Appendix
jsonup
+ typia
jsonup
はTypeScriptで書かれたJSON Parserです。
JSON形式のLiteral Stringを与えると型推論を行うという、謎技術 Library です(本人曰くネタで作ったそうですが、TypeScriptのCompilerの限界に挑戦してる感が好きです)。
そして、jsonup
とtypia
を組み合わせるとなんと、文字列からvalidation関数が生成されるという、なんとも不思議なことができます。
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 }
GitHubからコードを落とせるのでぜひ遊んでみてください。
自分はjsonup
やtype-fest
のような型パズルの Library が大好きなので、これらに息を吹き込めるようなtypia
はとても楽しいです。
typefuck
+ typia
typefuck
とは、型レベルで実装されたBrainfuck interpreterです。
この実装により、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です。
たとえば、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
での実装例をzod
とvalibot
で書いた例と比較してみましょう。
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ほどになりました。
開発環境について
ひとりごと
今回の unplugin-typia
は npm
ではなく JSR
で公開されています。
JSR
は npm
と同じようにパッケージを公開できるサービスですが、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
との相性は抜群ですからね。
リンクなど
- Typia
- Typia Setup Guide
- 作者によるTypiaについての解説記事
- unplugin-typia
- unplugin-typia/examples
- bun-typia-template
- bun-typia-jsonup-experiments
Discussion
bundle sizeについて記事を書きました