🐡

TypeScriptの型でドメインモデリング⛅️

2024/09/23に公開
4

紹介すること

TypeScriptの型を厳密に定義し、ドメインモデリングを行います。
ソースコードがドキュメントとして機能することを目指します。具体的には、Branded Typeやタグ付きユニオンなどの技法を扱います。

この記事は、TypeScriptをこれから使いこなしてみたい方向けの内容です。

紹介しないこと

ドメイン駆動開発や関数型プログラミングの概念については、深くは触れません。

Make Illegal States Unrepresentable

あり得る型だけを定義するという考え方です。
詳細は以下を御覧ください。
https://fsharpforfunandprofit.com/posts/designing-with-types-making-illegal-states-unrepresentable/

この考え方は、ほとんどのTypeScript開発に適用できると思います。

例1

仕様:

  • すべてのユーザは、id、nameを持っている
  • 認証されるとisVerifiedtrueになり、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

r-sugir-sugi

とても参考になりました!

質問「Branded Type」と「バリデーションを含む型のモデリング」のユースケースってどういうときなのでしょうか?

classを使わずにValueObjectに近いものを実現する際に利用するイメージでしょうか。

Yoshiyuki SatoYoshiyuki Sato

ご質問ありがとうございます!!

ご認識の通りです!class を使用しても問題ありませんが、今回の記事では関数型のアプローチで実装しています!

nak2knak2k

「タグ付きユニオン」ですが、通常その名の通り Union 型を使うものではないでしょうか?
型ガードなくてもタグの値で型の絞り込みができる Union 型です。

参考: Tagged Union Types in TypeScript — Marius Schulz

Yoshiyuki SatoYoshiyuki Sato

ありがとうございます!ご指摘のとおりでした🙇

「タグ付きユニオン」の項目を追加し、
「TypeScriptを使って識別を自作した例」で以前タグ付きユニオンとして紹介していた内容を書きました!