TypeScriptでエンティティをモデル化する際、次のようなインターフェースを得ることは非常に一般的です。
interface User {
id: number
username: string
}
interface Order {
id: number
userId: number
title: string
year: number
month: number
day: number
amount: { currency: 'EUR' | 'USD', value: number }
}
問題点
プロパティの型には意味がありません。型の観点から見ると、User.id
、Order.id
、Order.year
などはすべて同じです:数字であり、数字としては互換性がありますが、意味的にはそうではありません。
前の例に従って、エンティティに対して操作を行う関数のセットを作成できます。例えば:
function getOrdersFiltered(userId: number, year: number, month: number, day: number, amount: number) { // ...}
function deleteOrder(id: number) { // ... }
これらの関数は、数値の意味に関係なく、任意の引数で任意の数字を受け入れます。例えば:
const id = getUserId()
deleteOrder(id)
明らかに、それは大きな間違いであり、コードを読むのを避けるのは簡単に思えるかもしれませんが、コードは常に例のように単純ではありません。
同様のことがgetOrdersFiltered
にも当てはまります:日と月の値を入れ替えることができ、警告やエラーは発生しません。エラーは、日が12より大きい場合に発生しますが、結果が期待通りでないことは明らかです。
解決策
オブジェクトカリステニクスのルールはそれに対する解決策を提供します:すべてのプリミティブと文字列をラップします(関連プリミティブへの執着に関するアンチパターン)。ルールは、意味を表すオブジェクトでプリミティブをラップすることです(DDDではこれをValueObjects
と呼びます)。
しかし、TypeScriptでは、それにクラスやオブジェクトを使用する必要はありません:年を表すものとは異なる数字が年の代わりに使えないことを保証するために型システムを使用できます。
ブランデッドタイプ
このパターンは、意味を保証するプロパティを追加するために型の拡張性を利用します:
type Year = number & { __brand: 'year' }
このシンプルな行は、数値として機能する新しい型を作成しますが、実際には数値ではなく、年です。
const year = 2012 as Year
function age(year: Year): number { //... }
age(2012) // ❌ IDE will show an error as 2012 is not a Year
age(year) // ✅
解決策の一般化
ブランドごとに型を書くのを避けるために、次のようなユーティリティ型を作成できます。
declare const __brand: unique symbol
export type Branded<T, B> = T & { [__brand]: B }
これは、プロパティとの競合を避けるためにブランドプロパティ名としてユニークなシンボルを使用し、元の型とブランドをジェネリックパラメータとして取得します。
これにより、モデルと関数を次のようにリファクタリングできます。
type UserId = Branded<number, 'UserId'>
type OrderId = Branded<number, 'OrderId'>
type Year = Branded<number, 'Year'>
type Month = Branded<number, 'Month'>
type Day = Branded<number, 'Day'>
type Amount = Branded<{ currency: 'EUR' | 'USD', value: number}, 'Amount'>
interface User {
id: UserId
username: string
}
interface Order {
id: OrderId
userId: UserId
title: string
year: Year
month: Month
day: Day
amount: Amount
}
function getOrdersFiltered(userId: UserId, year: Year, month: Month, day: Day, amount: Amount) { // ...}
function deleteOrder(id: OrderId) { // ... }
この例では、IDEはid
がUserId
であり、deleteOrder
がOrderId
を期待しているため、エラーを表示します。
const id = getUserId()
deleteOrder(id) // ❌ IDE will show an error as id is UserID and deleteOrder expects OrderId
トレードオフ
小さなトレードオフとして、X
をBrand
として使用する必要があります。たとえば、プリミティブから新しい値を作成する際には、const year = 2012 as Year
としますが、これは値オブジェクトを使用する場合、new Year(2012)
に相当します。「コンストラクタ」のように機能する関数を提供することもできます。
function year(year: number): Year {
return year as Year
}
ブランド型による検証
ブランド型は、特定の型を持つ検証済みデータがあるため、データが有効であることを保証するのにも便利です。ユーザーが検証されたことを信頼できます。
type User = { id: UserId, email: Email}
type ValidUser = Readonly<Brand<User, 'ValidUser'>>
function validateUser(user: User): ValidUser {
// Checks if user is in the database
if (!/* logic to check the user is in database */) {
throw new InvalidUser()
}
return user as ValidUser
}
// We can not pass just a User, needs to be a ValidUser
function doSomethingWithAValidUser(user: ValidUser) {
}
Readonly
は必須ではありませんが、検証後にデータが変更されないようにするためには、非常に推奨されます。
まとめ
ブランド型は、次の内容を含むシンプルな解決策です。
- コードの可読性を向上させる:各引数で使用すべき値が明確になります。
- 信頼性: コード内の検出が難しい間違いを避けるのに役立ちます; 現在、IDE(および型チェック)が、値が正しい場所にあるかどうかを検出するのに役立ってくれます
- データ検証: ブランド型を使用してデータが有効であることを確認できます。
ブランド型は、クラスを使用せずに、単に型と関数を使ったValueObjects
の一種のバージョンと考えることができます。
型の力を楽しんでください!
Source:
https://dzone.com/articles/branded-types-in-typescript