こんにちは、新卒エンジニアのid:d-kimuson です
先日type-predicates-generator という型定義からユーザー定義型ガード・アサーション関数を自動生成するツールをリリースして紹介記事を書いたのですが、感想とかを眺めていたら同じく外部から来た値に安全な型付けをするためのライブラリやツールの情報をいくつも観測しました
この辺りのランタイムチェックライブラリの情報ってあまりまとまっていない印象で自分が知らないものもいくつかあったので、調べつつ簡単にまとめられたらなと思ってこのエントリを書きました
外部からやってきた値を型安全にするにはざっくりと
の 3 つのアプローチがあると思うので、それぞれのアプローチごとに紹介します
外部から値がやってくる主たるケースは API 通信で、GraphQL や OpenAPI のスキーマから型定義を自動生成することで型安全性を守るアプローチです
スキーマと生成ツールの実装が正しいという前提の元ですが、外部からやってくる値に正しさを一定担保した上で型をつけることができます
GraphQL のスキーマから型定義を自動生成するツールです
自分は Gatsby で個人の技術ブログを書いていて、そこで使用してます
公式サイト に例が載っているので、見てみるとイメージしやすいと思います
OpenAPI スキーマから型安全に API を呼べる API クライアントを自動生成してくれるツールです
// openapi-generator + axios のサンプルコードimport axiosfrom"axios"import{ PetApiFactory, Configuration, PetStatusEnum}from"./typescript-axios"// 自動生成されたコードconst api= axios.create({/* config here */})const config=new Configuration({/* config here */})exportconst endpoints= PetApiFactory(config,`baseURL`, api)// APIコールendpoints .getPetById(0/* 引数に型が付く */) .then((response)=>{ response.data/* Pet に型が付く */})
GitHub - aspida/aspida: TypeScript friendly HTTP client wrapper for the browser and node.js.
aspida という型安全に API コールを行うためのライブラリがあり、openapi2aspida を使うことでスキーマから aspida のクライアントを自動生成してくれます
// aspida のサンプルコードimport axiosfrom"axios"import aspidafrom"@aspida/axios"import apifrom"./api/$api"// 自動生成されたコードconst client= api(aspida(axios))// API コールclient.pet ._petId(0/* 引数に型が付く */) .get() .then((response)=>{ response.body/* Pet に型が付く */})
openapi-generator は TS に限らず様々な言語のクライアントを生成しますが、openapi2aspida は TypeScript 専用でより使いやすい印象です
自分は API を叩くときは aspida を使うことが多いです
この辺りのツールは型安全性の担保ももちろんですが、すでに存在するスキーマと同じ型を手動で書かなくて良い点も嬉しいポイントですね
TypeScript では型定義からランタイムのコードを生成することはできません
なので型定義とは別にランタイムチェック用の型を書いてそれでチェックしようというアプローチです
ランタイムチェック用の型はライブラリが指定する独自の書き方で宣言する必要があるので、重複管理になりそうに思うかもしれませんが、値から TS の型を取り出すのは難しくないので、ランタイムチェック用の型から TypeScript の型を拾えるようになっています
GitHub - gcanti/io-ts: Runtime type system for IO decoding/encoding
言わずとしれたランタイム型チェックのライブラリの王道です
io-ts の指定する形で型定義を書いてランタイムチェックを行えます
// io-ts のサンプルコードimport *as tfrom'io-ts'import{ isRight}from'fp-ts/lib/Either'const UserRuntimeType= t.type({ id: t.number, name: t.string, union: t.union([t.string, t.number]), optional: t.union([t.string, t.undefined]), nullable: t.union([t.string, t.null]),})type User/* : { id: number; name: string; union: string | number; nullable: string | null; optional?: string | undefined;} */= t.TypeOf<typeof UserRuntimeType>const maybeUser:unknown='invalid'const user= UserRuntimeType.decode(maybeUser)if(isRight(user)){// ランタイムバリデーションが成功したときだけこのブロックを通る user.right/* : User に型が付く */}
isRight とかに関数型っぽさが見え隠れしますね
GitHub - pelotom/runtypes: Runtime validation for static types
io-ts と同様に独自の構文でランタイムチェックの型を宣言してチェックします
io-ts は fp-ts に依存していて書き方も関数型チックになるので、関数型に寄るのを好まないケースで使われる印象です
// runtypes のサンプルコードimport{Number,String, Undefined, Null, Record, Union, Static,}from'runtypes'const UserRuntimeType= Record({ id:Number, name:String, union: Union(String,Number), optional: Union(String, Undefined), nullable: Union(Null, Undefined),})type User= Static<typeof UserRuntimeType>const maybeUser:unknown='invalid'const user= UserRuntimeType.check(maybeUser)// おかしかったら error を投げるuser/* User に型が付く */
io-ts は decode 時に型ガードを行いますが、runtypes ではバリデーションして値がおかしかったら例外を投げるという形のようです
これは自分が知らなかったライブラリなのですが、結構人気のあるライブラリらしくスターも 5600 ついていました
ドキュメントもかなり充実していました
// superstruct のサンプルコードimport{object,number,string, Infer, assert, union, optional, nullable, is,}from'superstruct'const UserRuntimeType=object({ id:number(), name:string(), union: union([string(),number()]), optional: optional(string()), nullable: nullable(string()),})type User= Infer<typeof UserType>const maybeUser:unknown='invalid'if(is(maybeUser, UserRuntimeType)){ maybeUser/* User に型が付く */}assert(maybeUser, UserRuntimeType)// バリデーションに失敗したら例外が発生maybeUser/* User に型が付く */
型の絞り込みはアサーションと型ガード両方に対応しているようです
Utility types に対応する omit, partial, pick も使えるらしく表現力がかなり豊かそうで好感触でした
ランタイムチェックはしたいけど io-ts は合わないって方にはファーストチョイスになりそうです
GitHub - colinhacks/zod: TypeScript-first schema validation with static type inference
上の2つと同様の Zod が提供するデータ型でスキーマを宣言して、ランタイムチェックを行います
// zod のサンプルコードimport{ z}from"zod"const UserRuntimeType= z.object({ id: z.number(), name: z.string(), union: z.union([z.string(), z.number()]), optional: z.union([z.string(), z.undefined()]), nullable: z.union([z.null(), z.string()]),})type User= z.infer<typeof UserRuntimeType>const maybeUser:unknown="invalid"const user= UserRuntimeType.parse(maybeUser)// 失敗したら例外を投げるuser/* User に型が付く */const result= UserRuntimeType.safeParse("invalid")if(result.success){ maybeUseras User// 型ガードは非対応 (unknown のまま) だがバリデーションはできるので、型キャストは安全}
型ガードには対応してないようですがバリデーション自体はできるので型キャストは一応安全です
また今回の値の型が TypeScript の型通りかをランタイムチェックするという趣旨とは若干ズレるので詳しくは触れませんが、ajv,yup,joi 等のバリデーションライブラリを使う手段もあります
② のアプローチでは TS の型を一時の型にしたい状況にはあまり適しません
使うことができないわけではありませんが、型情報が二重管理になってしまいます
具体的には
等です
実行時に TS の型から値を作ることはできませんが、事前に型情報からコード生成をすることなら可能なのでコード生成によって対応しようというアプローチです
GitHub - rhys-vdw/ts-auto-guard: Generate type guard functions from TypeScript interfaces
cli が提供されていて
$ ts-auto-guard ./path/to/type.ts
すると、type.guard.ts に型ガード関数が生成されてインポートして使うことができるようです
// ts-auto-guard のサンプルコードimport{ isUser}from'./type.guard'const maybeUser:unknown='invalid'if(isUser){ maybeUser/* : User */}
今回僕が作ったツールです、詳細は紹介記事を書いたばかりなのでそちらに譲りますが watch を立てておき、型定義の変更にリアルタイムに追従してランタイムチェック関数を自動生成することができます
$ type-predicates-generator -f 'types/**/*/ts' -o predicates.ts -a -w
// type-predicates-generator のサンプルコードimport{ isUser, assertIsUser}from'/path/to/predicates'const maybeUser:unknown='invalid'if(isUser){ maybeUser/* : User */}assertIsUser(maybeUser)maybeUser/* : User */
GitHub - woutervh-/typescript-is
typescript-is は少し特殊でttypescript という TypeScript にデフォルト以外の transform 処理を挟むツールとセットで使うことで、ビルド時にランタイムチェック関数を生成することができます
// typescript-is のサンプルコードimport{ is}from'typescript-is'type User={ id:number name:string}const maybeUser:unknown='invalid'if(is<User>(maybeUser)){ maybeUser}
本来はis<User>() のような形で User 型に合わせたようなチェック関数を作ることはできませんが、カスタムトランスフォーマーで前の 2 つと同じようなコード生成をビルド時に行うことでトランスパイル後のファイルにランタイムチェックを書き出すことができます
上の if 文は以下のようにトランスパイルされます
if ( (0, typescript_is_1.is)(maybeUser, object =>{function _number(object){if (typeof object !=='number')return{}elsereturnnull}function _string(object){if (typeof object !=='string')return{}elsereturnnull}function _0(object){if (typeof object !=='object' || object ===null ||Array.isArray(object) )return{}{if ('id'in object){var error = _number(object['id'])if (error)return error}elsereturn{}}{if ('name'in object){var error = _string(object['name'])if (error)return error}elsereturn{}}returnnull}return _0(object)})){ maybeUser}
といった成約はあると思いますが、直感的かつ手軽に値の型を守ることができます
プリミティブ等の型を気軽にチェックするには、as-safely というライブラリが手軽です。isString 等のランタイムチェック関数が提供されています
import{ asSafely, isString}from'as-safely'const maybeStr:unknown='valid'asSafely(maybeStr, isString)// チェックに失敗したら例外を投げるmaybeStr/* string に型がつく */
カスタムのランタイムチェック関数も使用できるので、② や ③ のライブラリ/ツールと組み合わせてアサーションを手軽に行うこともできます
例えば ts-auto-guard はアサーション関数の自動生成を提供しないようですが、asSafely とセットで使うと手軽にアサーションも行うことができます
import{ asSafely}from'as-safely'import{ isUser}from'./type.guard'const maybeUser:unknown='invalid'asSafely(maybeUser, isUser)maybeUser/* : User に型が付く */
外部からやってきた値に安全に型をつける方法について3つのアプローチに分けて紹介しました!
io-ts が一番有名だと思いますが、これに限らず複数の選択肢があるのでプロジェクトにあった形で型を守れると良いのではないでしょうか
個人的には
辺りになりそうかなという印象でした
それでは良い型安全ライフを!
引用をストックしました
引用するにはまずログインしてください
引用をストックできませんでした。再度お試しください
限定公開記事のため引用できません。