every Tech Blog

株式会社エブリーのTech Blogです。

TypeScriptのコードをBranded Primitiveでもう1歩型安全へ

お久しぶりです,トモニテ開発部でSoftware Engineer(SE)をしている鈴木です.
私が普段実装しているトモニテ相談室のフロントエンドはTypeScriptを採用しているのですが,トモニテ相談室の実装中にTypeScriptでは検出することが出来ないミスをしてしまい,原因解明までに時間を要した経験があります.
この経験からTypeScriptを普段より少し型安全にする手法を学んだので,本記事で具体例を交えながら紹介させていただこうと思います.

はじめに

TypeScriptは型を区別するための方式として構造的型付けを採用しています.
したがって,type宣言子による宣言は単に構造に対してエイリアスを張っているに過ぎず,トランスパイラはエイリアスの参照先の構造のみを検査しています.
この自由度は名前的型付けとは対称的であり,TypeScriptがJavaScriptに対してシームレスに型システムを導入することが出来た要因の一つとなっています.
一方で,この自由度ゆえにエンジニアがミスをしてしまった場合にもトランスパイラが見逃してしまう可能性があります.
どのようなミスを見逃してしまうのかを早速皆さんに共有させていただきたいところですが,逸る気持ちを抑え,まずは構造的型付けと名前的型付けの特徴を簡単に整理します.

構造的型付けと名前的型付け

型システムが型を区別するための方式には構造的型付け(Strucural Typing)と名前的型付け(Nominal Typing)の2種類があります.
前者は型の区別の際に型の"構造"に着目し,後者は型の区別の際に型に与えられた"名前"に着目します(両者とも読んで字の如くですね).
したがって,以下のような型TUがあったとき,構造的型付けでは型TUは等しいと見なされ,名前的型付けでは型TUは異なると判定されます.

type T = number;
type U = number;

以下のようなオブジェクト型の場合も同様です.

type User = {
    id: number;
    name: string;
}
type Counselor = {
    id: number;
    name: string;
}

構造的型付けが原因で見逃してしまうミス

以下のような,ユーザーIDを渡すと該当するIDを持つユーザーを返すTypeScriptの関数を考えます.

function getUserById(id: User['id']): User {
    return {
        id: 1,
        name: "鈴木",
    };
}

以下のようにUser['id']型の値を渡した場合にはもちろん想定通りの挙動をします.

const userId: User['id'] = 1;
const ret = getUserById(userId)

ここで,getUserByIdに対してCounselor['id']型の値を渡すことを考えてみます.
引数idUser['id']型であることから,これ以外の型の値を渡した場合にはトランスパイラが検出し,エンジニアにメッセージを出力して欲しいものです.
しかし,期待に反してトランスパイラは以下のようにCounselor['id']型の値を渡した場合も何もメッセージを出力すること無く,問題なくトランスパイルを終えます.

const counselorId: Counselor['id'] = 1;
const ret = getUserById(counselorId)

これはTypeScriptが型を区別するための方式として構造的型付けを採用していることが原因です.
先述の通り,type宣言子はあくまで構造に対してエイリアスを張るだけであるため,User['id']Counselor['id']number型にエイリアスを張っているに過ぎず,トランスパイラは両者を区別しないのです!
これは良し悪しではなく,単に言語仕様なので仕方のない事なのですが,サービス上の各モデルが共通で持つidのようなプロパティは区別出来るとエンジニアのミスが減り,開発速度の向上に繋がります.
つまり,TypeScriptをもう一歩型安全に近づけるために,TypeScriptで名前的型付けを再現し,idのような共通プロパティを区別出来るようにしたいのです.
構造的に型を区別するTypeScriptにそのような方法はあるのでしょうか?

Branded Primitive

Branded Primitiveという手法を用いることでTypeScriptで名前的型付けを再現することが可能です!
この手法はTypeScriptのgithubのwikiやオライリー・ジャパンから出版されている「プログラミングTypeScript―スケールするJavaScriptアプリケーション開発」(Boris Cherny 著、今村 謙士 監訳、原 隆文 訳)で紹介されており,弊社社内でエンジニア同士のコミュニケーションの際に用いる場合はBrand化と称しています.
number型をBrand化する際には以下のようにします.

type T = number & { readonly brand: unique symbol };
type U = number & { readonly brand: unique symbol };

上記のように,型TUを区別したい場合,それぞれnumber型と互いにプロパティを区別できるオブジェクト型の交差型を定義するのがBrand化です(※1).
このようにするとオブジェクト型の部分が異なることから構造も異なり,TUは互いに異なる型になります.
この時点で名前的型付けを再現出来ているのですが,更にnumber型とオブジェクト型の交差型はnumber型のサブタイプであるため,number型が持つtoStringなどのようなメソッドにも問題なくアクセス出来るのもBrand化のメリットの一つになります.
なお,型TまたはUを持つ値を生成する際には型アサーションが必要となります(※2).
上述のUser型やCounselor型をBrand化すると以下のようになります.

type User = {
    id: number & { readonly brand: unique symbol };
    name: string;
}
type Counselor = {
    id: number & { readonly brand: unique symbol };
    name: string;
}

このようにidの定義にBrand化を適用することにより,無事User['id']型とCounselor['id']型を区別できるようになりました!

Brand化を適用したnumber型の区別

※1
オブジェクト型の部分は互いに区別出来ればどのような形状になっていても構いません.
okunokentaroさんのZennの記事を学習の際に大いに活用させていただいたのですが,その記事で紹介されているジェネリクスを参考に以下のようなジェネリクスを定義するとBrand化の手間が少なくなるかと思います.
ただし,型パラメータTに同じリテラル型を渡してしまうと構造が一致し区別がつかなくなることには注意が必要です.

type BrandedNumber<T extends string> = number & { brand: T };

type User = {
    id: BrandedNumber<'User'>;
    name: string;
}
type Counselor = {
    id: BrandedNumber<'Counselor'>;
    name: string;
}

※2
各所で型アサーションをするのは手間やミスに繋がってしまうため,以下のような生成関数を定義すると良いです.

function UserId(id: number): User['id'] {
    return id as User['id']
}
const userId = UserId(1)

まとめ

本記事では私の実体験を元にTypeScriptをもう一歩型安全にする手法を紹介させていただきました.
TypeScriptは型を区別するための方式に構造的型付けを採用しており,この方式が持つ自由度ゆえに本来意図していない型を利用してしまった場合にもトランスパイラが検出出来ない可能性があります.
Branded Primitiveという手法がTypeScript公式wikiに掲載されており,この手法を区別したい型に対して適用することによって上述のようなミスをトランスパイラが検出出来るようになり,エンジニアのミスを仕組みで解決出来るようになります.
この記事が私と同じようなミスをしてしまった経験のある開発者の方々のお役に立てたら大変嬉しいです.
ここまでお読みいただきありがとうございました!