io-ts
ライブラリ
io-ts
概要
io-tsはTypeScript専用のランタイム型システムライブラリで、「IO(入出力)のデコード/エンコード」に特化した関数型プログラミングアプローチを採用しています。静的な型チェックしか提供しないTypeScriptの制限を補完し、実行時での型安全性を保証します。fp-tsライブラリとの深い統合により、Either、Option等のモナド機構を活用し、エラーハンドリングと型変換を統合的に処理できます。2025年現在も関数型プログラミングとTypeScriptを組み合わせた高度な型安全性を求めるプロジェクトで重要な役割を担っています。
詳細
io-ts 2.2系は2025年現在の最新安定版で、TypeScriptの型システムとfp-ts(関数型プログラミングライブラリ)の豊富な抽象化を基盤として構築されています。コーデック(Codec)システムにより、一つの定義から静的型とランタイムバリデーターの両方を生成できる「単一真実の源」を実現。Eitherモナドによる合成可能なエラーハンドリング、HKT(Higher Kinded Types)による高度な型抽象化、Schemableパターンによる拡張可能性を提供し、関数型プログラミングの原則に従った型安全なアプリケーション開発を支援します。
主な特徴
- ランタイム型システム: 実行時の型チェックとバリデーション
- コーデックベース設計: デコード/エンコード機能を統合した型定義
- fp-ts統合: Either、Option等のモナドによる関数型エラーハンドリング
- 単一真実の源: 一つの定義から静的型とランタイムバリデーターを生成
- 高度な合成性: 小さなコーデックから複雑な型を組み立て可能
- Schemableパターン: 拡張可能な型クラスベースの設計
メリット・デメリット
メリット
- TypeScriptの型安全性を実行時まで拡張
- fp-tsエコシステムとの完全統合による強力な抽象化
- 関数型プログラミングの原則に基づく設計
- 型定義の重複排除(静的型とランタイムバリデーターの統一)
- 詳細でカスタマイズ可能なエラー情報
- 高度な合成性と拡張性
デメリット
- fp-tsの知識が必要で学習コストが高い
- 関数型プログラミングに慣れていない開発者には複雑
- Eitherモナドベースのエラーハンドリングが冗長になる場合
- 他のバリデーションライブラリより記述量が多い
- コミュニティサイズがZodやYupより小さい
- 実装の複雑性により実行時パフォーマンスオーバーヘッド
参考ページ
書き方の例
インストールと基本セットアップ
# io-tsのインストール
npm install io-ts
yarn add io-ts
pnpm add io-ts
# fp-tsも必要(依存関係として必須)
npm install fp-ts
# TypeScript設定必要
# TypeScript 4.7以上が推奨
基本的なコーデック定義とバリデーション
import * as t from 'io-ts';
import { isLeft, isRight } from 'fp-ts/Either';
import { PathReporter } from 'io-ts/PathReporter';
// 基本的なコーデックの定義
const User = t.type({
id: t.number,
name: t.string,
email: t.string,
age: t.number,
isActive: t.boolean
});
// 静的型の抽出(TypeScript コンパイル時)
type User = t.TypeOf<typeof User>;
// 結果: { id: number; name: string; email: string; age: number; isActive: boolean; }
// ランタイムバリデーションの実行
const userData = {
id: 1,
name: '田中太郎',
email: '[email protected]',
age: 30,
isActive: true
};
// 成功例
const result = User.decode(userData);
if (isRight(result)) {
console.log('バリデーション成功:', result.right);
// result.right は User 型として安全に使用可能
} else {
console.log('バリデーション失敗:', PathReporter.report(result));
}
// 失敗例
const invalidData = {
id: 'invalid', // 数値ではなく文字列
name: '山田花子',
email: '[email protected]',
age: '25', // 数値ではなく文字列
isActive: 'true' // ブール値ではなく文字列
};
const invalidResult = User.decode(invalidData);
if (isLeft(invalidResult)) {
console.log('バリデーションエラー:');
PathReporter.report(invalidResult).forEach(error => {
console.log(` ${error}`);
});
}
// オプショナルフィールドとデフォルト値
const UserWithOptional = t.type({
id: t.number,
name: t.string,
profile: t.union([
t.type({
bio: t.string,
website: t.union([t.string, t.null])
}),
t.undefined
])
});
// 部分的なオブジェクト(全フィールドがオプショナル)
const PartialUser = t.partial({
name: t.string,
email: t.string,
age: t.number
});
type PartialUser = t.TypeOf<typeof PartialUser>;
// 結果: { name?: string; email?: string; age?: number; }
// プリミティブ型のバリデーション
const stringCodec = t.string;
const numberCodec = t.number;
const booleanCodec = t.boolean;
const nullCodec = t.null;
const undefinedCodec = t.undefined;
console.log(isRight(stringCodec.decode('hello'))); // true
console.log(isRight(numberCodec.decode(42))); // true
console.log(isRight(booleanCodec.decode(true))); // true
配列、レコード、複雑な構造のバリデーション
import * as t from 'io-ts';
import { pipe } from 'fp-ts/function';
import { fold } from 'fp-ts/Either';
// 配列のコーデック
const NumberArray = t.array(t.number);
const StringArray = t.array(t.string);
type NumberArray = t.TypeOf<typeof NumberArray>; // number[]
type StringArray = t.TypeOf<typeof StringArray>; // string[]
// レコード(辞書)のコーデック
const StringRecord = t.record(t.string);
const NumberRecord = t.record(t.number);
type StringRecord = t.TypeOf<typeof StringRecord>; // Record<string, string>
type NumberRecord = t.TypeOf<typeof NumberRecord>; // Record<string, number>
// ネストしたオブジェクトの定義
const Address = t.type({
street: t.string,
city: t.string,
postalCode: t.string,
country: t.string
});
const Company = t.type({
name: t.string,
industry: t.string,
employees: t.number
});
const ComplexUser = t.type({
id: t.number,
personalInfo: t.type({
firstName: t.string,
lastName: t.string,
birthDate: t.string // ISO date string
}),
contactInfo: t.type({
email: t.string,
phone: t.union([t.string, t.null])
}),
address: Address,
company: t.union([Company, t.null]),
tags: t.array(t.string),
preferences: t.record(t.union([t.string, t.number, t.boolean])),
metadata: t.unknown // 任意のJSONデータ
});
// 複雑なデータの検証
const complexData = {
id: 1,
personalInfo: {
firstName: '太郎',
lastName: '田中',
birthDate: '1990-05-15'
},
contactInfo: {
email: '[email protected]',
phone: '090-1234-5678'
},
address: {
street: '渋谷区道玄坂1-2-3',
city: '東京',
postalCode: '150-0043',
country: 'Japan'
},
company: {
name: '株式会社サンプル',
industry: 'IT',
employees: 100
},
tags: ['developer', 'typescript', 'javascript'],
preferences: {
theme: 'dark',
notifications: true,
maxItems: 50
},
metadata: {
source: 'manual_entry',
version: 1,
additional: {
notes: 'Test user data'
}
}
};
// Either モナドによるエラーハンドリング
const validateComplexUser = (data: unknown) => {
return pipe(
ComplexUser.decode(data),
fold(
// エラーハンドリング(Left の場合)
(errors) => {
console.log('バリデーションエラー:');
PathReporter.report({ _tag: 'Left', left: errors }).forEach(error => {
console.log(` ${error}`);
});
return null;
},
// 成功処理(Right の場合)
(validUser) => {
console.log('バリデーション成功');
console.log(`ユーザー: ${validUser.personalInfo.firstName} ${validUser.personalInfo.lastName}`);
console.log(`会社: ${validUser.company?.name || 'なし'}`);
console.log(`タグ数: ${validUser.tags.length}`);
return validUser;
}
)
);
};
validateComplexUser(complexData);
// タプル(固定長配列)のバリデーション
const Coordinate = t.tuple([t.number, t.number]);
const NamedCoordinate = t.tuple([t.string, t.number, t.number]);
type Coordinate = t.TypeOf<typeof Coordinate>; // [number, number]
type NamedCoordinate = t.TypeOf<typeof NamedCoordinate>; // [string, number, number]
const coordResult = Coordinate.decode([35.6762, 139.6503]);
console.log('座標バリデーション:', isRight(coordResult));
// Union 型(いずれかの型)
const Status = t.union([
t.literal('pending'),
t.literal('approved'),
t.literal('rejected')
]);
const NumberOrString = t.union([t.number, t.string]);
const OptionalString = t.union([t.string, t.null, t.undefined]);
type Status = t.TypeOf<typeof Status>; // 'pending' | 'approved' | 'rejected'
type NumberOrString = t.TypeOf<typeof NumberOrString>; // number | string
カスタムコーデックと高度なバリデーション
import * as t from 'io-ts';
import { pipe } from 'fp-ts/function';
import { chain, map } from 'fp-ts/Either';
// カスタムバリデーション用のブランド型
interface EmailBrand {
readonly Email: unique symbol;
}
type Email = string & EmailBrand;
interface PositiveNumberBrand {
readonly PositiveNumber: unique symbol;
}
type PositiveNumber = number & PositiveNumberBrand;
// カスタムコーデック: メールアドレス検証
const EmailCodec = new t.Type<Email, string, unknown>(
'Email',
(input): input is Email =>
typeof input === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input),
(input, context) => {
if (typeof input !== 'string') {
return t.failure(input, context, 'Expected string');
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input)) {
return t.failure(input, context, 'Invalid email format');
}
return t.success(input as Email);
},
t.identity
);
// カスタムコーデック: 正の数値検証
const PositiveNumberCodec = new t.Type<PositiveNumber, number, unknown>(
'PositiveNumber',
(input): input is PositiveNumber =>
typeof input === 'number' && input > 0,
(input, context) => {
if (typeof input !== 'number') {
return t.failure(input, context, 'Expected number');
}
if (input <= 0) {
return t.failure(input, context, 'Expected positive number');
}
return t.success(input as PositiveNumber);
},
t.identity
);
// 日付文字列のカスタムコーデック
interface DateStringBrand {
readonly DateString: unique symbol;
}
type DateString = string & DateStringBrand;
const DateStringCodec = new t.Type<DateString, string, unknown>(
'DateString',
(input): input is DateString =>
typeof input === 'string' && !isNaN(Date.parse(input)),
(input, context) => {
if (typeof input !== 'string') {
return t.failure(input, context, 'Expected string');
}
if (isNaN(Date.parse(input))) {
return t.failure(input, context, 'Invalid date string');
}
return t.success(input as DateString);
},
t.identity
);
// カスタムコーデックを使用した型定義
const ValidatedUser = t.type({
id: PositiveNumberCodec,
name: t.string,
email: EmailCodec,
registeredAt: DateStringCodec,
age: PositiveNumberCodec
});
// 使用例
const validateUserData = (data: unknown) => {
const result = ValidatedUser.decode(data);
if (isRight(result)) {
const user = result.right;
console.log('ユーザー検証成功:');
console.log(`ID: ${user.id}`);
console.log(`名前: ${user.name}`);
console.log(`メール: ${user.email}`); // Email ブランド型として型安全
console.log(`登録日: ${user.registeredAt}`); // DateString ブランド型
console.log(`年齢: ${user.age}`); // PositiveNumber ブランド型
return user;
} else {
console.log('ユーザー検証失敗:');
PathReporter.report(result).forEach(error => console.log(` ${error}`));
return null;
}
};
// テストデータ
const validUserData = {
id: 1,
name: '田中太郎',
email: '[email protected]',
registeredAt: '2025-01-01T00:00:00Z',
age: 30
};
const invalidUserData = {
id: -1, // 負の数値
name: '山田花子',
email: 'invalid-email', // 無効なメール形式
registeredAt: 'not-a-date', // 無効な日付
age: 0 // 正の数ではない
};
console.log('=== 有効なデータのテスト ===');
validateUserData(validUserData);
console.log('\n=== 無効なデータのテスト ===');
validateUserData(invalidUserData);
// 再帰的な型定義(自己参照型)
interface Category {
id: number;
name: string;
parent?: Category;
children: Category[];
}
const CategoryCodec: t.Type<Category> = t.recursion('Category', () =>
t.type({
id: t.number,
name: t.string,
parent: t.union([
t.recursion('Category', () => CategoryCodec),
t.undefined
]),
children: t.array(t.recursion('Category', () => CategoryCodec))
})
);
// 条件付きバリデーション
const ConditionalSchema = t.type({
type: t.union([t.literal('user'), t.literal('admin')]),
name: t.string,
permissions: t.union([t.array(t.string), t.undefined])
});
// カスタムバリデーション: 管理者には権限が必要
const validateConditionalData = (data: unknown) => {
return pipe(
ConditionalSchema.decode(data),
chain((decoded) => {
if (decoded.type === 'admin' && (!decoded.permissions || decoded.permissions.length === 0)) {
return t.failure(data, [], 'Admin users must have permissions');
}
return t.success(decoded);
})
);
};
Either モナドと関数型エラーハンドリング
import * as t from 'io-ts';
import * as E from 'fp-ts/Either';
import { pipe } from 'fp-ts/function';
import { sequenceT } from 'fp-ts/Apply';
// 複数のバリデーション結果を合成
const UserInput = t.type({
name: t.string,
age: t.number,
email: t.string
});
const CompanyInput = t.type({
name: t.string,
industry: t.string
});
// 複数のコーデックを並行してバリデーション
const validateMultipleInputs = (userData: unknown, companyData: unknown) => {
const userResult = UserInput.decode(userData);
const companyResult = CompanyInput.decode(companyData);
// sequenceT を使用して複数の Either を合成
return pipe(
sequenceT(E.Apply)(userResult, companyResult),
E.map(([user, company]) => ({
user,
company,
combined: {
userName: user.name,
companyName: company.name,
relationship: `${user.name} works at ${company.name}`
}
}))
);
};
// カスタムエラーレポーター
interface CustomError {
field: string;
message: string;
value: unknown;
}
const createCustomErrorReporter = (validation: t.Validation<any>): CustomError[] => {
if (E.isRight(validation)) return [];
const errors: CustomError[] = [];
const processError = (error: t.ValidationError): void => {
const field = error.context.map(c => c.key).filter(Boolean).join('.');
const message = error.message || `Invalid value at ${field}`;
errors.push({
field: field || 'root',
message,
value: error.value
});
};
validation.left.forEach(processError);
return errors;
};
// 関数型スタイルでのバリデーション処理
const processUserRegistration = (userData: unknown) => {
return pipe(
UserInput.decode(userData),
E.mapLeft(createCustomErrorReporter), // エラーをカスタム形式に変換
E.chain((user) => {
// 追加のビジネスロジックバリデーション
if (user.age < 18) {
return E.left([{
field: 'age',
message: '18歳以上である必要があります',
value: user.age
}]);
}
return E.right(user);
}),
E.map((user) => {
// 成功時の処理(例:データベース保存)
return {
...user,
id: Math.random(), // 仮のID生成
registeredAt: new Date().toISOString(),
status: 'active' as const
};
}),
E.fold(
// エラーハンドリング
(errors) => {
console.log('ユーザー登録失敗:');
errors.forEach(error => {
console.log(` ${error.field}: ${error.message} (値: ${error.value})`);
});
return null;
},
// 成功処理
(registeredUser) => {
console.log('ユーザー登録成功:', registeredUser);
return registeredUser;
}
)
);
};
// 使用例
const testUserData = {
name: '田中太郎',
age: 25,
email: '[email protected]'
};
const testCompanyData = {
name: '株式会社サンプル',
industry: 'IT'
};
console.log('=== 複数入力の検証 ===');
const multiResult = validateMultipleInputs(testUserData, testCompanyData);
if (E.isRight(multiResult)) {
console.log('両方のバリデーション成功:', multiResult.right.combined);
} else {
console.log('バリデーション失敗');
}
console.log('\n=== ユーザー登録処理 ===');
processUserRegistration(testUserData);
console.log('\n=== 年齢制限エラーのテスト ===');
processUserRegistration({ ...testUserData, age: 16 });
API統合とデータ変換パターン
import * as t from 'io-ts';
import * as E from 'fp-ts/Either';
import * as TE from 'fp-ts/TaskEither';
import { pipe } from 'fp-ts/function';
// API レスポンス用コーデック
const APIUser = t.type({
user_id: t.number,
full_name: t.string,
email_address: t.string,
birth_date: t.string,
is_active: t.boolean,
created_at: t.string,
profile: t.partial({
bio: t.string,
website: t.string,
avatar_url: t.string
})
});
// アプリケーション内部で使用する型
const AppUser = t.type({
id: t.number,
name: t.string,
email: t.string,
birthDate: t.string,
isActive: t.boolean,
createdAt: t.string,
profile: t.partial({
bio: t.string,
website: t.string,
avatarUrl: t.string
})
});
type APIUser = t.TypeOf<typeof APIUser>;
type AppUser = t.TypeOf<typeof AppUser>;
// API レスポンスから内部形式への変換
const convertAPIUserToAppUser = (apiUser: APIUser): AppUser => ({
id: apiUser.user_id,
name: apiUser.full_name,
email: apiUser.email_address,
birthDate: apiUser.birth_date,
isActive: apiUser.is_active,
createdAt: apiUser.created_at,
profile: {
bio: apiUser.profile?.bio,
website: apiUser.profile?.website,
avatarUrl: apiUser.profile?.avatar_url
}
});
// 内部形式からAPI送信用への変換
const convertAppUserToAPIUser = (appUser: AppUser): APIUser => ({
user_id: appUser.id,
full_name: appUser.name,
email_address: appUser.email,
birth_date: appUser.birthDate,
is_active: appUser.isActive,
created_at: appUser.createdAt,
profile: {
bio: appUser.profile?.bio,
website: appUser.profile?.website,
avatar_url: appUser.profile?.avatarUrl
}
});
// TaskEither を使用した非同期バリデーション
const fetchAndValidateUser = (userId: number): TE.TaskEither<string, AppUser> => {
return pipe(
TE.tryCatch(
// API コール
async () => {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json();
},
(error) => `API call failed: ${error}`
),
TE.chainEitherK((data) =>
pipe(
APIUser.decode(data),
E.mapLeft((errors) =>
`Validation failed: ${PathReporter.report(E.left(errors)).join(', ')}`
)
)
),
TE.map(convertAPIUserToAppUser)
);
};
// バッチ処理での型安全性
const processBatchUsers = async (userIds: number[]) => {
const results = await Promise.all(
userIds.map(id => fetchAndValidateUser(id)())
);
const { successes, errors } = results.reduce(
(acc, result, index) => {
if (E.isRight(result)) {
acc.successes.push(result.right);
} else {
acc.errors.push({ userId: userIds[index], error: result.left });
}
return acc;
},
{ successes: [] as AppUser[], errors: [] as { userId: number; error: string }[] }
);
return { successes, errors };
};
// フォームデータのバリデーションとサニタイゼーション
const UserFormData = t.type({
name: t.string,
email: t.string,
age: t.string, // フォームからは文字列で来る
bio: t.union([t.string, t.undefined])
});
const sanitizeAndValidateFormData = (formData: unknown) => {
return pipe(
UserFormData.decode(formData),
E.chain((data) => {
// サニタイゼーション処理
const sanitized = {
name: data.name.trim(),
email: data.email.toLowerCase().trim(),
age: parseInt(data.age, 10),
bio: data.bio?.trim()
};
// 変換後の再バリデーション
const SanitizedUser = t.type({
name: t.string,
email: t.string,
age: t.number,
bio: t.union([t.string, t.undefined])
});
return pipe(
SanitizedUser.decode(sanitized),
E.mapLeft(() => 'サニタイゼーション後のバリデーションに失敗しました')
);
}),
E.chain((data) => {
// ビジネスルール検証
if (data.name.length < 2) {
return E.left('名前は2文字以上である必要があります');
}
if (data.age < 18 || data.age > 120) {
return E.left('年齢は18歳以上120歳以下である必要があります');
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
return E.left('有効なメールアドレスを入力してください');
}
return E.right(data);
})
);
};
// 実用例: Express.js ミドルウェア
const createValidationMiddleware = <T>(codec: t.Type<T>) => {
return (req: any, res: any, next: any) => {
const result = codec.decode(req.body);
if (E.isLeft(result)) {
return res.status(400).json({
error: 'Validation failed',
details: PathReporter.report(result)
});
}
req.validatedBody = result.right;
next();
};
};
// 使用例
console.log('=== フォームデータのバリデーション ===');
const formData = {
name: ' 田中太郎 ',
email: '[email protected]',
age: '30',
bio: ' エンジニアです '
};
const formResult = sanitizeAndValidateFormData(formData);
if (E.isRight(formResult)) {
console.log('フォームバリデーション成功:', formResult.right);
} else {
console.log('フォームバリデーション失敗:', formResult.left);
}
高度なスキーマ合成とメタプログラミング
import * as t from 'io-ts';
import * as E from 'fp-ts/Either';
import { pipe } from 'fp-ts/function';
// スキーマの動的合成
const createEntitySchema = <T extends Record<string, t.Type<any>>>(
name: string,
fields: T
) => {
return t.type({
id: t.number,
createdAt: t.string,
updatedAt: t.string,
...fields
}, name);
};
// 基本エンティティスキーマの作成
const UserSchema = createEntitySchema('User', {
name: t.string,
email: t.string,
age: t.number
});
const PostSchema = createEntitySchema('Post', {
title: t.string,
content: t.string,
authorId: t.number,
published: t.boolean
});
// ページネーション用スキーマ
const createPaginatedSchema = <T>(itemCodec: t.Type<T>) => {
return t.type({
items: t.array(itemCodec),
pagination: t.type({
page: t.number,
perPage: t.number,
total: t.number,
totalPages: t.number
}),
metadata: t.partial({
filters: t.record(t.string),
sort: t.string,
search: t.string
})
});
};
const PaginatedUsers = createPaginatedSchema(UserSchema);
const PaginatedPosts = createPaginatedSchema(PostSchema);
// レスポンス用のラッパースキーマ
const createAPIResponseSchema = <T>(dataCodec: t.Type<T>) => {
return t.type({
success: t.boolean,
data: dataCodec,
message: t.union([t.string, t.null]),
timestamp: t.string,
requestId: t.string
});
};
const UserAPIResponse = createAPIResponseSchema(UserSchema);
const UsersListAPIResponse = createAPIResponseSchema(PaginatedUsers);
// 条件付きスキーマ(タグ付きユニオン)
const NotificationSchema = t.union([
t.type({
type: t.literal('email'),
recipient: t.string, // email address
subject: t.string,
body: t.string,
priority: t.union([t.literal('low'), t.literal('normal'), t.literal('high')])
}),
t.type({
type: t.literal('sms'),
phoneNumber: t.string,
message: t.string,
urgency: t.boolean
}),
t.type({
type: t.literal('push'),
deviceId: t.string,
title: t.string,
body: t.string,
badge: t.union([t.number, t.null]),
data: t.record(t.unknown)
}),
t.type({
type: t.literal('webhook'),
url: t.string,
method: t.union([t.literal('POST'), t.literal('PUT')]),
headers: t.record(t.string),
payload: t.unknown
})
]);
// 型の絞り込み関数
const isEmailNotification = (notification: t.TypeOf<typeof NotificationSchema>) => {
return notification.type === 'email';
};
const isSMSNotification = (notification: t.TypeOf<typeof NotificationSchema>) => {
return notification.type === 'sms';
};
// バリデーション結果に基づく処理の分岐
const processNotification = (data: unknown) => {
return pipe(
NotificationSchema.decode(data),
E.map((notification) => {
switch (notification.type) {
case 'email':
return {
channel: 'email',
recipient: notification.recipient,
content: `${notification.subject}: ${notification.body}`,
metadata: { priority: notification.priority }
};
case 'sms':
return {
channel: 'sms',
recipient: notification.phoneNumber,
content: notification.message,
metadata: { urgent: notification.urgency }
};
case 'push':
return {
channel: 'push',
recipient: notification.deviceId,
content: `${notification.title}: ${notification.body}`,
metadata: { badge: notification.badge, data: notification.data }
};
case 'webhook':
return {
channel: 'webhook',
recipient: notification.url,
content: JSON.stringify(notification.payload),
metadata: { method: notification.method, headers: notification.headers }
};
}
})
);
};
// インターセクション(交差型)を使用した拡張可能スキーマ
const TimestampMixin = t.type({
createdAt: t.string,
updatedAt: t.string
});
const AuditMixin = t.type({
createdBy: t.number,
updatedBy: t.number,
version: t.number
});
const SoftDeleteMixin = t.type({
deletedAt: t.union([t.string, t.null]),
deletedBy: t.union([t.number, t.null])
});
// ミックスインを組み合わせた完全なエンティティ
const createFullEntitySchema = <T extends Record<string, t.Type<any>>>(
name: string,
fields: T
) => {
return t.intersection([
t.type({
id: t.number,
...fields
}),
TimestampMixin,
AuditMixin,
SoftDeleteMixin
], name);
};
const FullUserSchema = createFullEntitySchema('FullUser', {
name: t.string,
email: t.string,
role: t.union([t.literal('user'), t.literal('admin'), t.literal('moderator')])
});
// 型抽出とタイプガード
type FullUser = t.TypeOf<typeof FullUserSchema>;
const isActiveUser = (user: FullUser): boolean => {
return user.deletedAt === null;
};
const isAdminUser = (user: FullUser): boolean => {
return user.role === 'admin' && isActiveUser(user);
};
// スキーマの動的検証機能
const validateEntityBatch = <T>(
codec: t.Type<T>,
data: unknown[]
): { valid: T[]; invalid: { index: number; errors: string[] }[] } => {
const valid: T[] = [];
const invalid: { index: number; errors: string[] }[] = [];
data.forEach((item, index) => {
const result = codec.decode(item);
if (E.isRight(result)) {
valid.push(result.right);
} else {
invalid.push({
index,
errors: PathReporter.report(result)
});
}
});
return { valid, invalid };
};
// 実用例
console.log('=== 通知処理のテスト ===');
const emailNotification = {
type: 'email',
recipient: '[email protected]',
subject: 'Welcome!',
body: 'Welcome to our service',
priority: 'normal'
};
const smsNotification = {
type: 'sms',
phoneNumber: '+81-90-1234-5678',
message: 'Your verification code is 123456',
urgency: true
};
[emailNotification, smsNotification].forEach((notification, index) => {
const result = processNotification(notification);
if (E.isRight(result)) {
console.log(`通知 ${index + 1} 処理成功:`, result.right);
} else {
console.log(`通知 ${index + 1} 処理失敗:`, PathReporter.report(result));
}
});
console.log('\n=== バッチバリデーションのテスト ===');
const userData = [
{
id: 1,
name: '田中太郎',
email: '[email protected]',
role: 'user',
createdAt: '2025-01-01T00:00:00Z',
updatedAt: '2025-01-01T00:00:00Z',
createdBy: 1,
updatedBy: 1,
version: 1,
deletedAt: null,
deletedBy: null
},
{
id: 'invalid', // 無効なID
name: '山田花子',
email: '[email protected]',
role: 'admin'
// 必須フィールドが不足
}
];
const batchResult = validateEntityBatch(FullUserSchema, userData);
console.log('有効なユーザー数:', batchResult.valid.length);
console.log('無効なデータ:', batchResult.invalid);