Branded Typesを導入してみる / TypeScript一人カレンダー
こんにちは、クレスウェア株式会社の奥野賢太郎 (@okunokentaro) です。本記事はTypeScript 一人 Advent Calendar 2022の18日目です。昨日は『NonNullable<T>と実例 assertExists()』を紹介しました。
Nominal TypingとStructural Typing
あらゆるプログラミング言語において、型付けという機構は言語ごとに異なります。このアドベントカレンダーで紹介している型はいずれもTypeScriptのためのもので、これらが他の言語、Java, Scala, Rust…などですべて互換な知識かというと、まったくそんなことはありません。包括的な概念としては似通う部分も多々ありますが、言語ごとに「この言語はどういった型付けの機構を有しているのか」を理解するのが好ましいです。それは言語ごとに処理系が異なり、用途が異なり、あるいは策定された年代や思想が異なり、それらを含めて「その言語の文化」が形成されているためです。
言語ごとの型の扱いは、ある型と異なるもうひとつの型の互換性を判定する仕組みで分類が可能です。ひとつはNominal Typing、もうひとつはStructural Typingであるという分類です。日本語の文献では、Nominal Typingのことを公称型、Structural Typingを構造的部分型と記すこともあります。
これらの話題はTypeScriptドキュメントのType Compatibilityにて解説されています。当該のサンプルコードを引用して解説します。
interface Pet {
name: string;
}
class Dog {
name: string;
}
let pet: Pet;
// OK, because of structural typing
pet = new Dog();
このコードでは、interface Petとclass Dogがどちらもname: stringというプロパティを有しており構造が一致することで、変数petにはDogのインスタンスが代入可能となっています。
つづいて本稿用にアレンジしたサンプルコードを紹介します。class Catに注目してください。
class Dog {
name: string;
}
class Cat {
name: string;
}
let dog: Dog;
dog = new Cat();
変数dogに対してCatインスタンスを代入していますが、これはエラーとなりません。なぜならば、class Dogとclass Catがどちらもname: stringというプロパティを有しており、構造が一致するためです。このようにclassやinterfaceの定義の違いではなく、その内部構造が一致するかどうかで型の互換性を判定する機構をStructural Typingといいます。
Type Alias
このアドベントカレンダーでも1日目から使っているtype宣言は、とても基礎的なキーワードですがTypeScript上ではType Aliasとして扱われます。エイリアスということは別名を意味しており、何らかの型に対して別名を与えているということです。
1日目に紹介したサンプルコードをもう一度確認しましょう。これは「() => string型」に対して「FunctionReturnString型」という別名を与えています。こうすることでコード中に頻繁に() => stringと記述する必要がなくなり、可読性の向上や保守性の向上を見込めます。
type FunctionReturnString = () => string;
あくまでも別名であるため、次のようなType Aliasの宣言は、同じ型であるとみなされます。
type UserId = string;
type ItemId = string;
どちらもstringの別名でしかないため、stringに2種類の別名があるにすぎません。ということは次のようなfindItem()関数を実装しても、その関数にUserId型の値を引数として渡し、コンパイルが成功してしまいます。
type UserId = string;
type ItemId = string;
type User = {
id: UserId;
name: string;
};
type Item = {
id: ItemId;
name: string;
imagePath: string;
};
declare function findItem(id: ItemId): Promise<Item>;
const user: User = {
id: "12345abcde",
name: "John Doe",
};
// ユーザーIDを使って商品を検索しようとしているが、エラーにならない
findItem(user.id);
これは、あくまでも別名であるためfindItem(id: string)に対してtype User = { id: string; }を渡したため、string同士が一致しエラーにならなかったということです。
Nominal Typing Likeなclass
前節のような事情から、Structural Typingを採用するTypeScriptにおいて、Type Aliasではなくちゃんとコンパイラが区別できる型を定義するにはどうすればよいか?と考える開発者が出始めました。これにはいくつか手法があり、まず紹介するのはNominal Typing Likeなclassです。なお、この分類は本稿で筆者が名付けたものです。
TypeScriptではプロパティの構造の一致によって型の互換性が求まります。ということは逆に、不一致であれば異なる型としてみなされます。つまり{ a: string }と{ b: string }は明らかに異なる型です。Nominal Typing Likeなclassは、この仕組みを利用してダミーのプロパティを定義しておくことで異なる型として扱われるようにしよう、という考え方です。
次のコードではUserId, ItemIdについてType Aliasではなくclassとして宣言しています。そのクラスの中のプロパティ定義に注目してください。
class UserId {
_userIdBrand!: never;
constructor(readonly v: string) {}
}
class ItemId {
_itemIdBrand!: never;
constructor(readonly v: string) {}
}
type User = {
id: UserId;
name: string;
};
type Item = {
id: ItemId;
name: string;
imagePath: string;
};
declare function findItem(id: ItemId): Promise<Item>;
const user: User = {
id: new UserId("12345abcde"),
name: "John Doe",
};
findItem(user.id);
_userIdBrandと_itemIdBrandという異なったプロパティを持つため、UserId型とItemId型には互換性がありません。ただし、このままでは_userIdBrandというダミーのプロパティになんの値を格納するのかという無用な疑問が生じてしまいます。そのため、筆者はこの用途でclassを宣言する際はあえてneverを指定していました。
TypeScriptではclass constructorの処理時に値がなにも代入されないプロパティについてエラーを返しますので、!をつけることで抑制しています。この用途ではTypeScript公式側はanyを採用しており解釈の分かれるところではありますが、筆者の場合anyはtypescript-eslintの抑制を書き足す必要があったため不採用としました。
Branded Types
つづいて、本稿のタイトルにもなっているBranded Typesを紹介します。先に結論からいうと、Nominal Typing Likeなclassの欠点を克服しており、筆者は近年の案件では常にBranded Typesを採用しています。この手法はいつ誰が言い出したのか定かではなく、筆者はそのひらめきを根源まで辿れていないため、あくまでも筆者が参考にした情報源しか掲載できないのですが、Michal Zalecki氏の掲載するアイデアを拝借しています。
Michal Zalecki氏のブログ中のサンプルコードを引用します。
type Brand<K, T> = K & { __brand: T }
type USD = Brand<number, "USD">
type EUR = Brand<number, "EUR">
const usd = 10 as USD;
const eur = 10 as EUR;
function gross(net: USD, tax: USD): USD {
return (net + tax) as USD;
}
gross(usd, usd); // ok
gross(eur, usd); // Type '"EUR"' is not assignable to type '"USD"'.
このように、Brand<K, T>型を宣言しておくことで、number型でありながら異なる構造をとるように__brandというダミープロパティを定義し、Tによって型が一致しないようにして、それをasでその型であるとみなすようにしています。asのことをType Assertionsといいます。
注意点として、これはusd.__brandというプロパティが実際に増えたわけではなく、あくまでもType Assertionsによって「コンパイラ側にそうみなしてもらっている」に過ぎないことが挙げられます。
Branded Types が解決したこと
Nominal Typing Likeなclassとの比較をして、どういった点がNominal Typing Likeなclassにおける欠点を克服しているかを紹介します。
Nominal Typing Likeなclassでは、クラスのインスタンスとして作られてしまうため、プリミティブではなくオブジェクトであるという点が最大の懸念になっていました。つまり次のようにidインスタンスを引数にとるgetPath()から文字列を得るような状況で問題が起こってしまうという懸念です。
class UserId {
_userIdBrand!: never;
constructor(readonly v: string) {}
}
function getPath(id: UserId): string {
return `/users/${id}/profile`;
}
console.log(getPath(new UserId("abcde12345")));
// "/users/abcde12345/profile" を期待している
// 実際は "/users/[object Object]/profile" である
この関数は無慈悲にも"/users/[object Object]/profile"を返却します。こうならないためには、idがオブジェクトであるためにid.vとしてプロパティの値を参照するか、あるいはclass UserId自体にtoString()メソッドを実装するしかありません。
これは「型同士の区別をしたい」という欲求自体からは離れてしまい、実装上の冗長さを生み出してしまいます。ゼロコストでもありません。
Branded Typesが優れているのは、ここでプリミティブをそのまま異なる型とみなせるようにしたことです。コンパイラには異なる型とみなさせているだけで、ECMAScriptとしての実行時には余計なプロパティやメソッドへのアクセスがありません。
また、オブジェクトではないということは、JSONのシリアライズにも強いです。class Userの場合はJSON.stringify()の結果は{"v":"abcde12345"}となってしまい、vプロパティの中に値の実体が存在することが露出してしまいます。Branded Typesの場合はプリミティブであるためシリアライズしても"abcde12345"のみが得られます。
昨今ではNext.jsのようにフロントエンドとバックエンドをまとめてTypeScriptで実装するというケースが増えてきました。この状況ではフロントエンドとバックエンドの橋渡しにJSONを採用することが多く、classインスタンスはJSONシリアライズ・デシリアライズに弱いという理由ですっかり採用しづらくなってしまいました。その事情からも、筆者の周辺においてはNominal Typing LikeなclassよりBranded Typesが業務上の欲求を満たすものとして採用できています。
明日は『実例 FilledString, UserId』
本日はTypeScriptの型の互換性を意識した扱いという、型安全を遂行する上で重要な観点を紹介しました。明日は本日紹介したBrand<K, T>にまだ少し残る課題を克服し、さらに安全性を高めた筆者流のBranded Typesを解説し、その実例を紹介していきます。それではまた。
Discussion