io-ts
TypeScriptの実行時型システムとバリデーションライブラリ
io-tsは、TypeScriptのための実行時型システムです。コンパイル時の型安全性と実行時のバリデーションを組み合わせることで、外部データの検証と型の整合性を保証します。関数型プログラミングのパラダイムに基づいて設計されており、fp-tsライブラリとの統合により強力な型安全性を実現します。
主な特徴
- 実行時型チェック - TypeScriptの型システムを実行時に拡張
- コンポーザブルな型定義 - 小さな型を組み合わせて複雑な型を構築
- 自動型推論 - コーデックから TypeScript の型を自動生成
- 詳細なエラー報告 - バリデーションエラーの詳細な情報を提供
- fp-ts統合 - 関数型プログラミングライブラリとのシームレスな連携
インストール
# npm
npm install io-ts fp-ts
# yarn
yarn add io-ts fp-ts
# pnpm
pnpm add io-ts fp-ts
基本的な使用方法
基本型の定義
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,
isActive: t.boolean
})
// TypeScript型の抽出
type User = t.TypeOf<typeof User>
// バリデーションの実行
const input = {
id: 1,
name: "田中太郎",
email: "[email protected]",
isActive: true
}
const result = User.decode(input)
if (isRight(result)) {
console.log('Valid user:', result.right)
} else {
console.log('Validation errors:', result.left)
}
オプショナルプロパティと部分型
import * as t from 'io-ts'
// 必須とオプショナルの混在
const Profile = t.intersection([
t.type({
userId: t.number,
displayName: t.string
}),
t.partial({
bio: t.string,
website: t.string,
location: t.string
})
])
// 使用例
const profile1 = Profile.decode({
userId: 1,
displayName: "ユーザー1"
}) // 成功
const profile2 = Profile.decode({
userId: 2,
displayName: "ユーザー2",
bio: "プログラマーです",
website: "https://example.com"
}) // 成功
高度な型定義
ユニオン型とリテラル型
import * as t from 'io-ts'
// リテラル型
const Status = t.union([
t.literal('pending'),
t.literal('approved'),
t.literal('rejected')
])
// より複雑なユニオン型
const Notification = t.union([
t.type({
type: t.literal('email'),
address: t.string,
subject: t.string
}),
t.type({
type: t.literal('sms'),
phoneNumber: t.string,
message: t.string
}),
t.type({
type: t.literal('push'),
deviceToken: t.string,
title: t.string,
body: t.string
})
])
type Notification = t.TypeOf<typeof Notification>
配列とレコード型
import * as t from 'io-ts'
// 配列型
const Tags = t.array(t.string)
// レコード型(動的なキー)
const Translations = t.record(t.string, t.string)
// 固定サイズの配列(タプル)
const Coordinate = t.tuple([t.number, t.number])
// 使用例
const BlogPost = t.type({
title: t.string,
tags: Tags,
translations: Translations,
location: Coordinate
})
const post = BlogPost.decode({
title: "io-tsの使い方",
tags: ["TypeScript", "バリデーション", "型安全"],
translations: {
en: "How to use io-ts",
ja: "io-tsの使い方"
},
location: [35.6762, 139.6503]
})
カスタムコーデック
ブランド型の作成
import * as t from 'io-ts'
import { pipe } from 'fp-ts/function'
import * as E from 'fp-ts/Either'
// Email型のブランド
interface EmailBrand {
readonly Email: unique symbol
}
const Email = t.brand(
t.string,
(s): s is t.Branded<string, EmailBrand> => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(s)
},
'Email'
)
type Email = t.TypeOf<typeof Email>
// 正の整数のブランド型
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'
)
リファインメント型
import * as t from 'io-ts'
// パスワードの複雑性チェック
const Password = t.refinement(
t.string,
(s) => s.length >= 8 && /[A-Z]/.test(s) && /[0-9]/.test(s),
'Password'
)
// 日付範囲の検証
const DateRange = t.refinement(
t.type({
start: t.string,
end: t.string
}),
({ start, end }) => new Date(start) <= new Date(end),
'DateRange'
)
エラーハンドリング
詳細なエラー情報の取得
import * as t from 'io-ts'
import { PathReporter } from 'io-ts/PathReporter'
import { isLeft } from 'fp-ts/Either'
const Person = t.type({
name: t.string,
age: PositiveInt,
email: Email,
address: t.type({
street: t.string,
city: t.string,
zipCode: t.string
})
})
const invalidData = {
name: "山田",
age: -5,
email: "invalid-email",
address: {
street: "東京都渋谷区",
// cityが欠けている
zipCode: "150-0002"
}
}
const result = Person.decode(invalidData)
if (isLeft(result)) {
const errors = PathReporter.report(result)
console.log('Validation errors:')
errors.forEach(error => console.log(error))
}
カスタムエラーメッセージ
import * as t from 'io-ts'
import { withMessage } from 'io-ts-types'
// カスタムエラーメッセージ付きの型
const JapanesePhoneNumber = withMessage(
t.refinement(
t.string,
(s) => /^0\d{9,10}$/.test(s),
'JapanesePhoneNumber'
),
() => '日本の電話番号形式(0から始まる10-11桁)で入力してください'
)
const ContactInfo = t.type({
phoneNumber: JapanesePhoneNumber
})
実践的な使用例
APIレスポンスの検証
import * as t from 'io-ts'
import * as E from 'fp-ts/Either'
import { pipe } from 'fp-ts/function'
// APIレスポンスの型定義
const ApiResponse = <T extends t.Any>(codec: T) =>
t.type({
success: t.boolean,
data: codec,
timestamp: t.string,
version: t.string
})
const UserResponse = ApiResponse(
t.array(User)
)
// API呼び出しとバリデーション
async function fetchUsers(): Promise<E.Either<t.Errors, User[]>> {
try {
const response = await fetch('/api/users')
const json = await response.json()
return pipe(
UserResponse.decode(json),
E.map(response => response.data)
)
} catch (error) {
return E.left([{
value: error,
context: [],
message: 'Network error'
}])
}
}
// 使用例
fetchUsers().then(result => {
pipe(
result,
E.fold(
errors => console.error('Failed to fetch users:', errors),
users => console.log('Users:', users)
)
)
})
フォームバリデーション
import * as t from 'io-ts'
import * as E from 'fp-ts/Either'
import { pipe } from 'fp-ts/function'
// 登録フォームの型定義
const RegistrationForm = t.type({
username: t.refinement(
t.string,
s => s.length >= 3 && s.length <= 20,
'Username'
),
email: Email,
password: Password,
confirmPassword: t.string,
agreeToTerms: t.literal(true)
})
// パスワード一致の追加検証
const validatePasswordMatch = (form: t.TypeOf<typeof RegistrationForm>) =>
form.password === form.confirmPassword
? E.right(form)
: E.left('パスワードが一致しません')
// フォーム検証関数
function validateRegistration(input: unknown) {
return pipe(
RegistrationForm.decode(input),
E.chain(validatePasswordMatch)
)
}
環境変数の検証
import * as t from 'io-ts'
import { pipe } from 'fp-ts/function'
import * as E from 'fp-ts/Either'
// 環境変数のスキーマ
const EnvConfig = t.type({
NODE_ENV: t.union([
t.literal('development'),
t.literal('production'),
t.literal('test')
]),
PORT: t.refinement(
t.string,
s => /^\d+$/.test(s) && parseInt(s) > 0 && parseInt(s) < 65536,
'Port'
),
DATABASE_URL: t.string,
JWT_SECRET: t.refinement(
t.string,
s => s.length >= 32,
'JWTSecret'
)
})
// 環境変数の読み込みと検証
function loadConfig(): E.Either<string, t.TypeOf<typeof EnvConfig>> {
return pipe(
EnvConfig.decode(process.env),
E.mapLeft(errors => PathReporter.report(E.left(errors)).join('\n'))
)
}
ベストプラクティス
1. 型の再利用とコンポジション
// 基本的な型を定義
const Id = t.number
const Timestamp = t.string
const NonEmptyString = t.refinement(
t.string,
s => s.length > 0,
'NonEmptyString'
)
// 組み合わせて使用
const BaseEntity = t.type({
id: Id,
createdAt: Timestamp,
updatedAt: Timestamp
})
const Product = t.intersection([
BaseEntity,
t.type({
name: NonEmptyString,
price: PositiveInt,
description: t.string
})
])
2. デコーダーの最適化
// 遅延評価を使用した再帰的な型
const Category: t.Type<Category> = t.recursion(
'Category',
() => t.type({
id: t.number,
name: t.string,
children: t.array(Category)
})
)
interface Category {
id: number
name: string
children: Category[]
}
3. テストの書き方
import { isRight, isLeft } from 'fp-ts/Either'
describe('User validation', () => {
it('should validate valid user', () => {
const validUser = {
id: 1,
name: "テストユーザー",
email: "[email protected]",
isActive: true
}
const result = User.decode(validUser)
expect(isRight(result)).toBe(true)
})
it('should reject invalid email', () => {
const invalidUser = {
id: 1,
name: "テストユーザー",
email: "invalid-email",
isActive: true
}
const result = User.decode(invalidUser)
expect(isLeft(result)).toBe(true)
})
})
関連リソース
- 公式ドキュメント
- fp-ts ドキュメント
- io-ts-types - 追加の型定義