io-ts

TypeScriptの実行時型システムとバリデーションライブラリ

io-tsは、TypeScriptのための実行時型システムです。コンパイル時の型安全性と実行時のバリデーションを組み合わせることで、外部データの検証と型の整合性を保証します。関数型プログラミングのパラダイムに基づいて設計されており、fp-tsライブラリとの統合により強力な型安全性を実現します。

主な特徴

  1. 実行時型チェック - TypeScriptの型システムを実行時に拡張
  2. コンポーザブルな型定義 - 小さな型を組み合わせて複雑な型を構築
  3. 自動型推論 - コーデックから TypeScript の型を自動生成
  4. 詳細なエラー報告 - バリデーションエラーの詳細な情報を提供
  5. 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)
  })
})

関連リソース