io-ts

シリアライゼーションTypeScript関数型プログラミングバリデーションfp-tsコーデック

ライブラリ

io-ts

概要

io-tsは、TypeScriptにおけるランタイム型システムを提供する関数型プログラミングライブラリです。コンパイル時の型安全性とランタイムでのデータ検証を統合し、外部からの入力データを安全に処理します。fp-tsエコシステムの一部として、Eitherモナドを使用したエラーハンドリングと、合成可能なコーデックによる複雑な型の構築を特徴とします。

詳細

io-tsは、エンコーダーとデコーダーのペア(コーデック)を中心に設計されています。各コーデックは、未知のデータを特定の型にデコードし、その型の値を外部表現にエンコードする機能を提供します。関数型プログラミングの原則に基づき、コーデックは純粋関数として実装され、合成や変換が容易です。型エラーは詳細なパスとコンテキスト情報と共に提供され、デバッグが容易になります。

主な特徴

  • コーデックベースの設計: エンコード/デコードの双方向変換
  • 関数型アプローチ: fp-tsとの完全な統合
  • 合成可能性: 小さなコーデックから複雑な型を構築
  • 詳細なエラー情報: 検証失敗時のパスとコンテキスト
  • ブランド型: 名目的型付けによる型安全性の向上
  • カスタムタイプ: 独自の検証ロジックの実装が容易

メリット・デメリット

メリット

  • 関数型プログラミングとの完璧な統合
  • 型安全性が非常に高い(ブランド型サポート)
  • エラーハンドリングが明示的で予測可能
  • コーデックの合成により複雑な型も表現可能
  • 純粋関数による副作用のない実装
  • 学術的に堅牢な理論的基盤

デメリット

  • 関数型プログラミングの知識が必要
  • 学習曲線が急(Either、モナド等の概念)
  • エラーメッセージがそのままでは読みにくい
  • ボイラープレートがやや多い
  • fp-tsへの依存が必須
  • 命令型プログラミングスタイルには不向き

参考ページ

書き方の例

基本的なコーデックの定義と使用

import * as t from 'io-ts';
import { isRight, isLeft } from 'fp-ts/Either';
import { PathReporter } from 'io-ts/PathReporter';

// 基本的なコーデック定義
const UserCodec = t.type({
  id: t.number,
  name: t.string,
  email: t.string,
  age: t.number,
  isActive: t.boolean
});

// TypeScript型の抽出
type User = t.TypeOf<typeof UserCodec>;

// デコード(検証)
const unknownData = {
  id: 1,
  name: '田中太郎',
  email: '[email protected]',
  age: 30,
  isActive: true
};

const decoded = UserCodec.decode(unknownData);

if (isRight(decoded)) {
  const user: User = decoded.right;
  console.log('Valid user:', user);
} else {
  const errors = PathReporter.report(decoded);
  console.error('Validation errors:', errors);
}

// エンコード
const user: User = {
  id: 1,
  name: '田中太郎',
  email: '[email protected]',
  age: 30,
  isActive: true
};

const encoded = UserCodec.encode(user);
console.log('Encoded:', encoded);

カスタム型とリファインメント

import * as t from 'io-ts';
import { either } from 'fp-ts/Either';
import { pipe } from 'fp-ts/function';

// Email型のカスタムコーデック
const EmailCodec = new t.Type<string, string, unknown>(
  'Email',
  (input): input is string => 
    typeof input === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input),
  (input, context) =>
    typeof input === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input)
      ? t.success(input)
      : t.failure(input, context, 'Invalid email format'),
  t.identity
);

// 正の整数型
const PositiveIntCodec = t.brand(
  t.number,
  (n): n is t.Branded<number, { readonly PositiveInt: unique symbol }> =>
    Number.isInteger(n) && n > 0,
  'PositiveInt'
);

// 日付文字列型(ISO 8601)
const DateStringCodec = new t.Type<Date, string, unknown>(
  'DateString',
  (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()
);

// 複合型での使用
const AdvancedUserCodec = t.type({
  id: PositiveIntCodec,
  email: EmailCodec,
  createdAt: DateStringCodec,
  metadata: t.partial({
    lastLogin: DateStringCodec,
    preferences: t.record(t.string, t.unknown)
  })
});

type AdvancedUser = t.TypeOf<typeof AdvancedUserCodec>;

ユニオン型と交差型

import * as t from 'io-ts';
import { fold } from 'fp-ts/Either';
import { pipe } from 'fp-ts/function';

// タグ付きユニオン(判別可能なユニオン)
const NotificationCodec = t.union([
  t.type({
    type: t.literal('email'),
    to: t.string,
    subject: t.string,
    body: 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,
    data: t.union([t.record(t.string, t.string), t.undefined])
  })
]);

type Notification = t.TypeOf<typeof NotificationCodec>;

// 交差型
const TimestampedCodec = <C extends t.Mixed>(codec: C) =>
  t.intersection([
    codec,
    t.type({
      createdAt: t.string,
      updatedAt: t.string
    })
  ]);

const TimestampedUserCodec = TimestampedCodec(
  t.type({
    id: t.number,
    name: t.string,
    email: t.string
  })
);

// 使用例
const processNotification = (data: unknown) => {
  return pipe(
    NotificationCodec.decode(data),
    fold(
      (errors) => {
        console.error('Invalid notification:', PathReporter.report(either.left(errors)));
        return null;
      },
      (notification) => {
        switch (notification.type) {
          case 'email':
            console.log(`Sending email to ${notification.to}`);
            break;
          case 'sms':
            console.log(`Sending SMS to ${notification.phoneNumber}`);
            break;
          case 'push':
            console.log(`Sending push notification to ${notification.deviceToken}`);
            break;
        }
        return notification;
      }
    )
  );
};

配列とレコードの処理

import * as t from 'io-ts';
import { fold } from 'fp-ts/Either';
import { pipe } from 'fp-ts/function';

// 商品コーデック
const ProductCodec = t.type({
  id: t.string,
  name: t.string,
  price: t.number,
  tags: t.array(t.string),
  attributes: t.record(t.string, t.union([t.string, t.number, t.boolean]))
});

// APIレスポンスコーデック
const ApiResponseCodec = <T extends t.Mixed>(dataCodec: T) =>
  t.union([
    t.type({
      status: t.literal('success'),
      data: dataCodec,
      meta: t.type({
        page: t.number,
        totalPages: t.number,
        totalItems: t.number
      })
    }),
    t.type({
      status: t.literal('error'),
      error: t.type({
        code: t.string,
        message: t.string,
        details: t.union([t.array(t.string), t.undefined])
      })
    })
  ]);

const ProductListResponseCodec = ApiResponseCodec(t.array(ProductCodec));

// 使用例
const fetchProducts = async (page: number) => {
  const response = await fetch(`/api/products?page=${page}`);
  const data = await response.json();
  
  return pipe(
    ProductListResponseCodec.decode(data),
    fold(
      (errors) => {
        console.error('Invalid API response:', errors);
        throw new Error('Invalid API response');
      },
      (validResponse) => {
        if (validResponse.status === 'success') {
          return {
            products: validResponse.data,
            meta: validResponse.meta
          };
        } else {
          throw new Error(validResponse.error.message);
        }
      }
    )
  );
};

fp-tsとの統合活用

import * as t from 'io-ts';
import * as E from 'fp-ts/Either';
import * as TE from 'fp-ts/TaskEither';
import * as A from 'fp-ts/Array';
import { pipe } from 'fp-ts/function';
import { PathReporter } from 'io-ts/PathReporter';

// ユーザー入力検証
const UserInputCodec = t.type({
  username: t.string,
  password: t.string,
  email: t.string,
  age: t.number
});

// バリデーションルール
const validateUsername = (username: string): E.Either<string, string> =>
  username.length >= 3 && username.length <= 20
    ? E.right(username)
    : E.left('Username must be between 3 and 20 characters');

const validatePassword = (password: string): E.Either<string, string> =>
  password.length >= 8 && /[A-Z]/.test(password) && /[0-9]/.test(password)
    ? E.right(password)
    : E.left('Password must be at least 8 characters with uppercase and number');

const validateAge = (age: number): E.Either<string, number> =>
  age >= 18 && age <= 120
    ? E.right(age)
    : E.left('Age must be between 18 and 120');

// 複合バリデーション
const validateUserInput = (input: unknown): TE.TaskEither<string[], t.TypeOf<typeof UserInputCodec>> =>
  pipe(
    TE.fromEither(UserInputCodec.decode(input)),
    TE.mapLeft((errors) => PathReporter.report(E.left(errors))),
    TE.chain((validInput) =>
      pipe(
        E.Do,
        E.apS('username', validateUsername(validInput.username)),
        E.apS('password', validatePassword(validInput.password)),
        E.apS('age', validateAge(validInput.age)),
        E.map(() => validInput),
        TE.fromEither,
        TE.mapLeft((error) => [error])
      )
    )
  );

// 使用例
const processUserRegistration = async (input: unknown) => {
  const result = await validateUserInput(input)();
  
  pipe(
    result,
    E.fold(
      (errors) => {
        console.error('Validation errors:', errors);
      },
      (validUser) => {
        console.log('Valid user registration:', validUser);
        // データベースに保存等
      }
    )
  );
};

高度な型構築パターン

import * as t from 'io-ts';
import { pipe } from 'fp-ts/function';
import * as E from 'fp-ts/Either';

// 再帰的な型定義
type TreeNode = {
  value: string;
  children: TreeNode[];
};

const TreeNodeCodec: t.Type<TreeNode> = t.recursion('TreeNode', () =>
  t.type({
    value: t.string,
    children: t.array(TreeNodeCodec)
  })
);

// 条件付き型
const PaymentMethodCodec = t.union([
  t.type({
    type: t.literal('credit_card'),
    cardNumber: t.string,
    cvv: t.string,
    expiryDate: t.string
  }),
  t.type({
    type: t.literal('bank_transfer'),
    accountNumber: t.string,
    routingNumber: t.string
  }),
  t.type({
    type: t.literal('paypal'),
    email: t.string
  })
]);

// 動的なスキーマ生成
const createPaginatedResponseCodec = <T extends t.Mixed>(itemCodec: T) => {
  return t.type({
    items: t.array(itemCodec),
    pagination: t.type({
      page: t.number,
      perPage: t.number,
      total: t.number,
      totalPages: t.number
    }),
    links: t.type({
      first: t.string,
      last: t.string,
      prev: t.union([t.string, t.null]),
      next: t.union([t.string, t.null])
    })
  });
};

// 使用例
const UserListCodec = createPaginatedResponseCodec(
  t.type({
    id: t.number,
    name: t.string,
    email: t.string
  })
);

type UserList = t.TypeOf<typeof UserListCodec>;