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はタグで型名を指します。
タグで同じ型を区別します。
type UserId = { type: 'UserId'; value: string };
type OrderId = { type: 'OrderId'; value: string };
type ProductCode = { type: 'ProductCode'; value: number };
type Property = UserId | OrderId | ProductCode;
// 値を生成する
const orderId: OrderId = { type: 'OrderId', value: 'orderiddesu' };
OrderId
型であれば、typeが型名を指し、typeにOrderId
以外の値は入りません。
valueは変数orderIdの値となります。
これにより、typeの値で型を識別することができます。
TypeScriptを使って識別を自作した例
次に、TypeScrtiprの機能を使ってBrand Typeやタグ付きユニオンと同じことをします。
タグ付きユニオンのときと同じように、orderIdに対して、typeとvalueの2つのプロパティがあるオブジェクトを用意します。
valueはorderIdの値を指し、typeはタグで型名を指します。
type Property = 'UserId' | 'OrderId' | 'ProductCode';
type Identifier<T extends Property, V> = { type: T; value: V };
const createIdentifier = <T extends Property, V>(
type: T,
value: V
): Identifier<T, V> => ({
type,
value
});
// 型を定義する
type OrderId = Identifier<'OrderId', string>;
// 値を生成する
const orderId: OrderId = createIdentifier('OrderId', 'orderiddesu');
この方法により、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
とても参考になりました!
質問「Branded Type」と「バリデーションを含む型のモデリング」のユースケースってどういうときなのでしょうか?
classを使わずにValueObjectに近いものを実現する際に利用するイメージでしょうか。
ご質問ありがとうございます!!
ご認識の通りです!class を使用しても問題ありませんが、今回の記事では関数型のアプローチで実装しています!
「タグ付きユニオン」ですが、通常その名の通り Union 型を使うものではないでしょうか?
型ガードなくてもタグの値で型の絞り込みができる Union 型です。
参考: Tagged Union Types in TypeScript — Marius Schulz
ありがとうございます!ご指摘のとおりでした🙇
「タグ付きユニオン」の項目を追加し、
「TypeScriptを使って識別を自作した例」で以前タグ付きユニオンとして紹介していた内容を書きました!