io-ts の使い方 — TypeScriptランタイム型バリデーションの定番ライブラリ
一言でいうと
io-ts は、TypeScriptの型定義とランタイムバリデーションを一つのスキーマ定義から同時に実現するライブラリです。外部から受け取るデータ(APIレスポンス、ユーザー入力など)が期待する型に合致するかを実行時に検証し、型安全なデコード/エンコードを提供します。
どんな時に使う?
- 外部APIのレスポンスを型安全にパースしたい —
fetchで取得したunknownなJSONデータを、TypeScriptの型に安全に変換したい場合 - ユーザー入力やフォームデータのバリデーション — サーバーサイドで受け取ったリクエストボディが正しい構造かをランタイムで検証したい場合
- 設定ファイルや環境変数の読み込み — JSONやYAMLから読み込んだ設定値が期待するスキーマに合致するかチェックしたい場合
インストール
fp-ts がピア依存として必要です。必ず一緒にインストールしてください。
# npm
npm install io-ts fp-ts
# yarn
yarn add io-ts fp-ts
# pnpm
pnpm add io-ts fp-ts
注意: 本記事は io-ts v2.2.x を前提としています。
基本的な使い方
最も典型的なパターンは、t.type() でオブジェクトのスキーマを定義し、decode メソッドでバリデーションを行うものです。
import * as t from 'io-ts';
import { isRight } from 'fp-ts/Either';
// スキーマ定義(ランタイムバリデータ + 型定義を兼ねる)
const User = t.type({
id: t.number,
name: t.string,
email: t.string,
});
// スキーマからTypeScriptの型を抽出
type User = t.TypeOf<typeof User>;
// => { id: number; name: string; email: string }
// 外部データのデコード(バリデーション)
const input: unknown = JSON.parse('{"id": 1, "name": "Alice", "email": "alice@example.com"}');
const result = User.decode(input);
if (isRight(result)) {
// バリデーション成功 — result.right は User 型として型安全に使える
console.log(result.right.name); // "Alice"
} else {
// バリデーション失敗 — result.left にエラー情報が入る
console.error('Validation failed:', result.left);
}
エラーメッセージを人間が読める形にする
io-ts のデフォルトのエラー出力は構造化されたオブジェクトで、そのままでは読みにくいです。io-ts-reporters や PathReporter を使うと整形できます。
import { PathReporter } from 'io-ts/PathReporter';
const badInput: unknown = { id: 'not-a-number', name: 42 };
const badResult = User.decode(badInput);
console.log(PathReporter.report(badResult));
// [
// 'Invalid value "not-a-number" supplied to : { id: number, name: string, email: string }/id: number',
// 'Invalid value 42 supplied to : { id: number, name: string, email: string }/name: string',
// 'Invalid value undefined supplied to : { id: number, name: string, email: string }/email: string'
// ]
よく使うAPI
1. t.type() — 必須プロパティのオブジェクト
すべてのプロパティが必須のオブジェクト型を定義します。
const Article = t.type({
title: t.string,
body: t.string,
publishedAt: t.string,
});
type Article = t.TypeOf<typeof Article>;
// => { title: string; body: string; publishedAt: string }
2. t.partial() — オプショナルプロパティのオブジェクト
すべてのプロパティがオプショナル(undefined 許容)になります。t.type() と t.intersection() を組み合わせて、必須 + オプショナルの混在を表現するのが定番パターンです。
const UpdateUser = t.intersection([
t.type({
id: t.number,
}),
t.partial({
name: t.string,
email: t.string,
bio: t.string,
}),
]);
type UpdateUser = t.TypeOf<typeof UpdateUser>;
// => { id: number; name?: string; email?: string; bio?: string }
3. t.union() — ユニオン型
複数の型のいずれかにマッチすればOKという型を定義します。
const Status = t.union([
t.literal('draft'),
t.literal('published'),
t.literal('archived'),
]);
type Status = t.TypeOf<typeof Status>;
// => 'draft' | 'published' | 'archived'
console.log(isRight(Status.decode('draft'))); // true
console.log(isRight(Status.decode('deleted'))); // false
4. t.array() — 配列型
要素の型を指定して配列のスキーマを作ります。
const Tags = t.array(t.string);
type Tags = t.TypeOf<typeof Tags>;
// => string[]
const UserList = t.array(User);
type UserList = t.TypeOf<typeof UserList>;
// => User[]
console.log(isRight(Tags.decode(['ts', 'fp']))); // true
console.log(isRight(Tags.decode([1, 2, 3]))); // false
5. t.brand() — ブランド型(Refined型)
プリミティブ型に追加の制約を付けたブランド型を定義できます。例えば「正の整数」のような型を表現できます。
interface PositiveIntBrand {
readonly PositiveInt: unique symbol;
}
const PositiveInt = t.brand(
t.number,
(n): n is t.Branded<number, PositiveIntBrand> => Number.isInteger(n) && n > 0,
'PositiveInt'
);
type PositiveInt = t.TypeOf<typeof PositiveInt>;
console.log(isRight(PositiveInt.decode(42))); // true
console.log(isRight(PositiveInt.decode(-1))); // false
console.log(isRight(PositiveInt.decode(3.14))); // false
その他の便利なコンビネータ
| コンビネータ | 用途 | 例 |
|---|---|---|
t.literal(value) | リテラル型 | t.literal('admin') |
t.keyof(keys) | キーの列挙(enum的) | t.keyof({ admin: null, user: null }) |
t.record(keys, values) | レコード型 | t.record(t.string, t.number) |
t.tuple(items) | タプル型 | t.tuple([t.string, t.number]) |
t.intersection([a, b]) | 交差型 | t.intersection([A, B]) |
t.readonly(codec) | Readonly化 | t.readonly(User) |
t.null / t.undefined | null / undefined | t.union([t.string, t.null]) |
類似パッケージとの比較
| 特徴 | io-ts | zod | yup | superstruct |
|---|---|---|---|---|
| TypeScript型推論 | ◎ | ◎ | △ | ○ |
| 依存ライブラリ | fp-ts 必須 | なし | なし | なし |
| 関数型プログラミング | ◎(Either/fp-ts) | × | × | × |
| エラー表現 | Either<Errors, A> | ZodError | ValidationError | StructError |
| バンドルサイズ | 中(+fp-ts) | 小 | 中 | 小 |
| 学習コスト | 高(fp-ts前提知識) | 低 | 低 | 低 |
| エンコード(出力変換) | ◎ | △ | × | × |
| メンテナンス状況 | 低頻度 | 活発 | 活発 | 活発 |
選定の目安:
- fp-ts を既に使っている / 関数型スタイルが好み → io-ts
- 手軽に始めたい / チームの学習コストを抑えたい → zod
- フォームバリデーション中心 → yup
注意点・Tips
1. fp-ts の Either に慣れておく
io-ts の decode は Either<Errors, A> を返します。isRight() / isLeft() や fold() / pipe() といった fp-ts の基本操作を理解していないと扱いにくいです。
import { pipe } from 'fp-ts/function';
import { fold } from 'fp-ts/Either';
const handleResult = pipe(
User.decode(input),
fold(
(errors) => `Validation failed: ${PathReporter.report(fold(errors)(errors))}`,
(user) => `Hello, ${user.name}!`
)
);
より実用的なパターン:
import { pipe } from 'fp-ts/function';
import * as E from 'fp-ts/Either';
function parseUser(input: unknown): User {
return pipe(
User.decode(input),
E.getOrElseW((errors) => {
throw new Error(PathReporter.report(E.left(errors)).join('\n'));
})
);
}
2. t.type は余分なプロパティを除去しない
t.type はデフォルトで余分なプロパティをそのまま通します(strip しない)。余分なキーを除去したい場合は t.exact() を使います。
const StrictUser = t.exact(t.type({
id: t.number,
name: t.string,
}));
// { id: 1, name: "Alice", extra: "foo" } → decode成功するが extra は除去される
3. Experimental モジュール(Decoder.ts / Codec.ts)について
v2.2+ で追加された Decoder / Encoder / Codec モジュールは、Stable API(t.type 等)とは独立した別系統のAPIです。混在させないよう注意してください。Experimental モジュールはより柔軟な設計ですが、破壊的変更のリスクがあります。
4. カスタムコーデックの作成
日付文字列を Date オブジェクトに変換するような、デコード + エンコードの双方向変換も定義できます。
const DateFromString = new t.Type<Date, string, unknown>(
'DateFromString',
(input): input is Date => input instanceof Date,
(input, context) => {
if (typeof input !== 'string') {
return t.failure(input, context, 'Expected string');
}
const date = new Date(input);
return isNaN(date.getTime()) ? t.failure(input, context, 'Invalid date string') : t.success(date);
},
(date) => date.toISOString()
);
// decode: string → Date
const decoded = DateFromString.decode('2024-01-15T00:00:00.000Z');
// encode: Date → string
const encoded = DateFromString.encode(new Date('2024-01-15'));
// => "2024-01-15T00:00:00.000Z"
5. io-ts-types で便利な型を追加
io-ts-types パッケージには、DateFromISOString、NumberFromString、NonEmptyString、UUID など、よく使うカスタム型が揃っています。
npm install io-ts-types
import { DateFromISOString } from 'io-ts-types/DateFromISOString';
import { NumberFromString } from 'io-ts-types/NumberFromString';
まとめ
io-ts は、TypeScriptの型システムとランタイムバリデーションを統一的に扱える強力なライブラリです。fp-ts エコシステムとの親和性が高く、関数型プログラミングのスタイルで堅牢なデータバリデーションパイプラインを構築できます。ただし fp-ts の学習コストがネックになるため、チームに fp-ts の経験者がいない場合は zod などのより手軽な代替も検討してみてください。