TypeScriptの型でドメインモデリング⛅️
紹介すること
TypeScriptの型を厳密に定義し、ドメインモデリングを行います。
ソースコードがドキュメントとして機能することを目指します。具体的には、Branded Typeやタグ付きユニオンなどの技法を扱います。
この記事は、TypeScriptをこれから使いこなしてみたい方向けの内容です。
紹介しないこと
ドメイン駆動開発や関数型プログラミングの概念については、深くは触れません。
Make Illegal States Unrepresentable
あり得る型だけを定義するという考え方です。
詳細は以下を御覧ください。
この考え方は、ほとんどのTypeScript開発に適用できると思います。
例1
仕様:
- すべてのユーザは、id、nameを持っている
- 認証されると
isVerified
がtrue
になり、verifiedDateが付与される - 認証前はisVerifiedがfalse
単純に型定義をすると、以下のようになりますが、ドメインを明確に表現できていません。
interface User {
id: number;
name: string;
isVerified: boolean;
verifiedDate?: Date;
}
この定義では、どのような状況でどのような値が入るのかがわからないため、ドキュメントとして機能していません。
ドメインモデリングを行うと、以下のようになります。
interface UserBase {
id: number;
name: string;
}
interface VerifiedUser extends UserBase {
isVerified: true;
verifiedDate: Date;
}
interface UnverifiedUser extends UserBase {
isVerified: false;
}
type User = VerifiedUser | UnverifiedUser;
これにより、認証されるとisVerifiedがtrueになり、verifiedDateが付与され、未認証の場合はisVerifiedがfalseになることが型定義から明確に理解できるようになりました。
認証と未認証ユーザで処理を分ける
型ガードを使って、以下のように書く事ができます。
// 型ガード
const isVerifiedUser = (user: User): user is VerifiedUser =>
user.isVerified === true;
const handleUser = (user: User): void => {
if (isVerifiedUser(user)) {
// 認証済みユーザ
console.log(
`Verified User: ${user.name}, Verified Date: ${user.verifiedDate}`
);
} else {
// 未認証ユーザ
console.log(`Unverified User: ${user.name}`);
}
};
このアプローチは、認証されたユーザはisVerifiedがtrueであると型定義しているからこそ可能です。
例2
顧客が持っている情報は次の通りです:
- メールアドレスのみ
- 住所のみ
- メールアドレスと住所の両方
type ContactInfo = { email?: string; address?: string };
type Customer = { Name: string; contactInfo: ContactInfo };
これに対し、ドメインを正しく表現できる型は以下のようになります。
type ContactInfo =
| { email: string }
| { address: string }
| { email: string; address: string };
type Customer = { Name: string; contactInfo: ContactInfo };
区別して型を定義する
例えば、orderIdの型定義をする際、一般的にはorderId: string;
としますが、この定義ではorderIdに対して別の値であるuserIdなどを代入することが可能です。
最初に考えられる型は以下の通りです。
type OrderId = string;
type UserId = string;
const orderId: OrderId = "orderIddesu";
const userId: UserId = "orderIddesu" // これも許可される
同じstring型であっても、型に名称を付与することで間違いを減らせますが、TypeScriptではOrderIdはstringのエイリアスであり、OrderIdとUserIdは同じ型と見なされるため、間違った値を代入することが考えられます。
ここで、OrderIdとUserIdを区別する方法を2つ紹介します。
Branded Type
型に対してBranded Typeを使うと同じstringを区別することができます。
以下のようなユーティリティ関数を作成します。
type Brand<K, T> = K & { __brand: T };
const createBrandTypeValue = <K, T>(value: K): Brand<K, T> => {
return value as Brand<K, T>;
}; // Branded Typeの値を生成する関数
例えば、以下のようにUserInfoとOrderIdにBranded Typeを使用して、同じstring型を区別します。
type UserInfo = { userId: string; name: string };
type OrderId = string;
// Branded Typeを定義する
type BrandUserInfo = Brand<UserInfo, "UserInfo">;
type BrandOrderId = Brand<OrderId, "string">;
// Branded Typeの値を生成する
const brandUserInfo: BrandUserInfo = createBrandTypeValue<
UserInfo, // 型を定義
'UserInfo' // 型名を定義
>(
{
userId: 'abcdefg',
name: 'john'
} // 値を定義
);
const brandOrderId = createBrandTypeValue<string, 'OrderId'>('orderIddesu');
このようにして、Branded Typeの値に対してcreateBrandTypeValue
関数を使用しないとエラーになります。
let brandOrderId = createBrandTypeValue<string, 'OrderId'>('orderIdです');
// Branded Typeの値に対して、string型の値を代入してみる
brandOrderId = 'orderIddesu'; // これはエラー
brandOrderId = createBrandTypeValue<string, 'OrderId'>('orderIddesu'); // これはOK
タグ付きユニオン
次にタグ付きユニオンを紹介します。
orderIdに対して、typeとvalueの2つのプロパティがあるオブジェクトを用意します。valueはorderIdの値を指し、typeはタグです。
タグで同じstringを区別します。
type Identifier<T extends string, V> = { type: T; value: V };
const createIdentifier = <T extends string, V>(
type: T,
value: V
): Identifier<T, V> => ({
type,
value
});
// 型を定義する
type OrderId = Identifier<'OrderId', string>;
// 値を生成する
const orderId: OrderId = createIdentifier('OrderId', 'orderiddesu'); // value が number
この方法により、createIdentifier
を使わないとOrderId型の値を生成できないため、Branded Typeと同様の制約が得られます。
バリデーションを含む型のモデリング
例えば、1以上1000以下の数字が入る値の場合、TypeScriptではその型を直接作ることができません。
TypeScriptを使用して擬似的に制約を付与する
Branded Typeを使用した関数を作成し、バリデーションを行います。
type Result<T, E> = { type: 'Ok'; value: T } | { type: 'Error'; error: E };
// 型定義
type UnitQuantity = { qty: number & { _brand: 'UnitQuantity' } };
// UnitQuantity型の値を生成するための関数
const createUnitQuantity = (qty: number): Result<UnitQuantity, string> => {
if (qty < 1) {
return { type: 'Error', error: 'UnitQuantity must be at least 1' };
} else if (qty >= 1000) {
return { type: 'Error', error: 'UnitQuantity must be less than 1000' };
} else {
return { type: 'Ok', value: { qty: qty as UnitQuantity['qty'] } };
}
};
// 使用例
const result = createUnitQuantity(500);
if (result.type === 'Ok') {
const validQuantity = result.value;
console.log('成功:', validQuantity); // qty: 500
} else {
console.log('エラー:', result.error);
}
上記の書き方だと、result.successがtrueのときだけ処理を続ける様になるので、複数の値を使用するときにif文が長くなってしまいそうです。
複数バリデーションあった場合以下のようにvalidateAll
関数を作り、簡潔に書けます。
type Result<T, E> =
| { type: 'Ok'; value: T }
| { type: 'Error'; error: E };
// 複数のバリデーションをして成功or失敗を返す関数
const validateAll = (results: Result<any, string>[]): Result<any, string> => {
for (const result of results) {
if (result.type === 'Error') {
return result;
}
}
return { type: 'Ok', value: null };
};
// バリデーションの実行例(架空の関数のバリデーション群)
const sample1 = createUnitQuantity(500);
const sample2 = createPercentage(50);
const sample3 = createUnitQuantity(300);
const sample4 = createPercentage(80);
const finalResult = validateAll([sample1, sample2, sample3, sample4]);
if (finalResult.type === 'Ok') {
console.log("全てのバリデーションが成功しました");
// ここで後続の処理などをする
} else {
console.log("エラー:", finalResult.error);
}
実装コスト、可読性を犠牲にしている感は否めませんが、型安全になったと思います。
Zodを使う
Zodを使用すると、可読性が上がり、実装も楽です。
createUnitQuantity
を使用するのは一緒なので、使い勝手は変わらないと思います。
import * as z from 'zod';
// 1以上1000未満の整数でバリデーションするスキーマ
const UnitQuantity = z.object({
qty: z
.number()
.min(1, { message: 'Number must be at least 1' })
.max(999, { message: 'Number must be less than 1000' })
.brand<'UnitQuantity'>() // ブランド型を付与
});
// 型定義
type UnitQuantity = z.infer<typeof UnitQuantity>;
// UnitQuantityを生成する関数
const createUnitQuantity = (qty: number): UnitQuantity =>
UnitQuantity.parse({ qty });
// 使用例
const unitQuantity: UnitQuantity = createUnitQuantity(1);
最後に
厳密な型定義を行うことで、コードの可読性や保守性が向上します。
これからTypeScriptを使いこなしたい方々にとって、本記事が有益であったなら幸いです。型安全性を意識しながら、より堅牢なアプリケーションを開発していきましょう。TypeScriptの可能性を感じながら、楽しいコーディングライフをお過ごしください♫
Discussion