21
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

株式会社カオナビAdvent Calendar 2024

Day 18

Branded Type ベストプラクティス 検索

Last updated at Posted at 2024-12-18

皆さんこんにちは。これは株式会社カオナビ Advent Calendar 2024の18日目の記事です。

今回は、TypeScriptにおけるBranded Typeについて、筆者が考えるベストプラクティスを紹介します。Branded Typeという概念はTypeScriptエンジニアの間で比較的広く知られていますが、(筆者的には)ベストではないやり方が紹介されていることが多いと感じます。この記事では筆者が一番いいと思っている方法を説明しますので、そうだなと思った方はいいねや拡散をしていただけると嬉しいです。

既存の記事でも、筆者の考えるベストプラクティスに近いものが説明されているいい記事はすでにたくさんあります。なので、未だにそれ以外の説明が使われることがあるのは少し不思議なのですが、状況をさらに改善するために筆者も記事を書くことにしました。

そのため、この記事が特段新しい知識を提供するわけではありませんが、自分の言葉で説明している文章があると便利で、ついでに自分のスタンスも明確にできるのでこの記事を用意しました。よろしければお付き合いください。

既存のいい記事の例

結論

unique symbol型を使う。

Branded Typeとは

まず、Branded Typeというテクニックについて改めて説明します。これは、文字列などのプリミティブの型を加工して、プリミティブなのに互いに区別可能な型を作る手法のことです。

例えば、記事投稿システムでIDを文字列で表現している場合、「ユーザーID」と「記事ID」を型で区別したくなるかもしれません。ユーザーIDを受け付ける関数に記事IDを渡すようなコードがあったらそれはバグでしょうから、型チェックで弾けたら嬉しいですね。

そのような場合に採用を検討できるのがBranded Typeです。

やり方は後回しにして先に使用例を見てみましょう。

import type { UserId } from "./user";
import type { PostId, Post } from "./post";

/**
 * 記事IDを受け取って記事を返す
 */
async function getPost(id: PostId): Promise<Post> {
  const dbPost = await db.post.findOne({
    id, // ← idはランタイムには文字列なのでそのまま検索条件に使える
  });
  // (略)
}

// セッション情報からユーザーIDを取得
const userId: UserId = session.user.id;

console.log(userId); // "uhyo" (ユーザーIDは実際には文字列)

// 間違ってgetPostにuserIdを渡しちゃった!
await getPost(userId);

この例は、最後の行でユーザーIDを誤って記事IDとして使用してしまっています。UserIdPostIdも実際には(ランタイムには)文字列です。そのためstringとしてもいいのですが、その場合はこのミスを型チェックで検出することができません。

しかし、UserIdPostIdをBranded Typesのテクニックでいい感じに定義しておけば、最後の行を型エラーで弾くことができます。

サンプルコードの最後の行で、userIdに赤い波線が引かれていて型エラーがあることを示している。

また、await getPost("mypost")のように、PostIdではないただの文字列をPostIdに渡そうとするのも型エラーとなります。

Branded Typeの基本形

unique symbolを使ってBranded Typeを実装する場合、以下の形が基本形となります。

Branded Typeの基本形
const userIdBrand = Symbol();

export type UserId = string & { [userIdBrand]: unknown };

export function createUserId(rawId: string): UserId {
  return rawId as UserId;
}

このように、Branded Type(この例ではUserId)は、プリミティブ型(string)と何らかのオブジェクト型のインターセクション型として定義します。こうすることで、ただのstringはオブジェクト型のところに適合しないため、UserIdに代入しようとしても型エラーとなります。

この例で、変数userIdBrandの型がunique symbol型です。これは特別なsymbol型であり、ひとつひとつのunique symbolは別々の型として認識されます。これはSymbol()のように新しいシンボルを生成する式に対して推論される特別な型です。

つまり、unique symbol型はすでに、Branded Typeのような性質(どちらもsymbol型だけど区別できる)を持っていることになります。この性質を応用したのが文字列に対するBranded Typeだと考えることができます。

unique symbolの性質
const userIdBrand = Symbol();
const postIdBrand = Symbol();

// これは型エラー(異なるSymbolであるため)
let a: typeof userIdBrand = postIdBrand;
// これも型エラー(異なるSymbolであるため)
const another: typeof postIdBrand = Symbol();
// これはOK
const b: typeof userIdBrand = userIdBrand;

そもそもシンボルは「オブジェクトのキーとして使える文字列以外の値」として導入されたもので、異なるシンボルは異なるキーとして扱われます。その性質をTypeScriptの型システムで再現するために、unique symbolという仕組みが導入されています。先ほどお見せしたBranded Typeの実装でも、unique symbolはオブジェクト型のキーとして使われています。

先ほどの説明のとおり、2つの異なるBranded Typeがある場合、互いに代入することはできません。

const userIdBrand = Symbol();
const postIdBrand = Symbol();
type UserId = string & { [userIdBrand]: unknown };
type PostId = string & { [postIdBrand]: unknown };

// 説明用にUserIdを作る
const myId = "uhyo" as UserId;

// これは型エラーになる
const postId: PostId = myId;

これは、UserIdPostIdのオブジェクト型の部分が一致しないからです。userIdBrandpostIdBrandは異なるシンボルであり、つまりオブジェクトのキーとして異なるということです。これは例えるならば、{a: unknown}{b: unknown}を比較しているようなものです。

unique symbolを使わない(ベストではない)やり方

以上のようにunique symbolを使うのが筆者が考えるベストプラクティスですが、では逆に、そうではないやり方も見ておきましょう。一つは、このようなやり方が見られます。

type UserId = string & { __userIdBrand__: unknown };
type PostId = string & { __postIdBrand__: unknown };

要するに、シンボルを使うのをやめて、代わりに文字列のオブジェクトのキーを使う方法です。これでも、UserIdPostIdは互いに区別されるようになるため、Branded Typeでやりたいことは達成できています。

このようなシンボルを使わないやり方がベストではないと筆者が考える理由は、主に2つあります。

一つは、単純に補完で余計なものが出てしまうことです。

コードエディタでuserId.と入力したときに表示される補完候補。一番上に__userIdBrand__がある。

unique symbolを使うやり方であれば補完候補に余計なものが増えません。シンボルそのもの(userIdBrand)を持っていないとアクセスできませんからね。

もう一つの理由は、これが嘘だからです。

このようなBranded Typeの定義だと、「UserIdは文字列であって、かつ__userIdBrand__というプロパティを持っているよ」という意味になります。しかし、実際(ランタイム)には__userIdBrand__というプロパティを持っていませんから、型が嘘をついていることになります。

型はランタイムの値を正しく反映しているというのが、型を信頼してコーディングをするための前提条件です。Branded Typeのためとはいえ、それを崩してしまうようなやり方はあまり気に入りません。

ただし、上の定義はまだ「マシな嘘」ではあります。なぜなら、実際にランタイムにユーザーID(ただの文字列)に対して__userIdBrand__にアクセスするとundefinedになるはずですが、型のunknownundefinedの可能性も含んでいますから、万が一__userIdBrand__にアクセスしても型安全性が破壊される状況にはならないでしょう。

どうしても型で嘘をつく必要があるときは、最低限守るべきラインとして、このように型安全性を壊さない程度の嘘を心がけるといいでしょう。

unique symbolは嘘ではないのか?

実を言うと、unique symbolを使うベストなやり方として紹介したものも、実は嘘ではあります。しかし、__userIdBrand__よりもさらにマシな嘘であると考え、ベストプラクティスとして紹介しています。

unique symbolを使うのがさらにマシな嘘である理由は、カプセル化と関係しています。

そもそも、シンボルという機能自体が、カプセル化の道具として使い勝手がいいものです。

オブジェクトのキーとして見たとき、シンボルと文字列の決定的な違いは何でしょうか。文字列がキーとなっているオブジェクトは、オブジェクトを持っていれば誰でもそのキーにアクセスできます。一方で、シンボルがキーとなっている場合、オブジェクトに加えて、そのシンボルそのものを持っていないと中身にアクセスできません。

メタプログラミング( Object.getOwnPropertySymbols )をすれば抜け穴があるのですが、正直メタプログラミングは設計と直交するもので、TSの型システムの範疇外でもあるためここでは考えないことにします。

unique symbolを使うBranded Typeの例を再掲します。userIdBrandexportされていない点に特に注目してください。

const userIdBrand = Symbol();

export type UserId = string & { [userIdBrand]: unknown };

export function createUserId(rawId: string): UserId {
  return rawId as UserId;
}

この例ではuserIdBrandをせず、UserIdcreateUserIdだけをエクスポートしています。

この型定義では「UserIdは文字列だけど、userIdBrandというシンボルをキーとしたプロパティがあるんだよ」という主張がされていますが、肝心のuserIdBrandはモジュールの中に秘匿されているため、モジュールの外からはそれが本当かどうか確認する術がありません。

つまり、このUserIdモジュールの中身を見られると嘘がばれるけど、モジュールの外からは嘘かどうか分からないのです。つまり、unique symbolを使うBranded Typeでは、嘘の範囲をアプリケーション全体から、1つのモジュールの中だけに抑えることができます。

この「外から見たら分からない」という考え方は、クラスのプライベートフィールドにも通ずるところがありますね。TypeScriptでは、プライベートフィールドを持つクラスの型も名前的型付けのような挙動をするようになり、Branded Typeに近い効果が得られます。実際、プライベートフィールドも、クラスの外からは存在に干渉できないという点で「秘匿されたシンボルをキーとするフィールド」と似ています。逆に言えば、Branded Typeは、クラスを使わずに同じような挙動を再現するテクニックだとも言えます。

ちなみに、createUserIdの中身を見るとasが使われていますね。asもまた、TypeScriptにおいて嘘をつく手段のひとつです。このようなBranded Typeでは、その型がついた値を得るためにasは不可欠なのですが、createUserId関数を提供することで、モジュールの外でas UserIdする必要が無くなります。

このように、userIdBrandをエクスポートしないことで、UserIdに関する嘘を1モジュールにしまい込むことができます。これはある種のカプセル化であり、これがうまくできるやり方だからこそ、筆者はunique symbolによるBranded Typeを支持しています。

今度はcreateUserIdの使用者が嘘をついていないか(ユーザーIDではない文字列をcreateUserIdUserIdに変換しちゃっていないか)をチェックをしなければならず、問題のレイヤーが移動しただけだということに気づいた聡明な読者もいるかもしれません。

それはその通りで、そこをどうするのかもまた設計の腕の見せ所だし、筆者はそういう点からそもそもBranded Type自体あまり好きではありません。

しかし、何はともあれ型システムのレイヤーでは問題が改善されたのでこの記事ではOKとします。

unique symbolを使わない他の例

先ほどはunique symbolを使わない(ベストではない)例として__userIdBrand__: unknown というやり方を紹介しましたが、unique symbolを使わない他のバリエーションも存在します。それがこちらです。

type UserId = string & { __brand__: "UserId" };
type PostId = string & { __brand__: "PostId" };

このようなやり方でもBranded Typeになりますが、これは先ほどの例よりもさらにおすすめしません。お察しのとおり、型安全性に影響を与えるタイプの嘘となっているからです。

unique symbolを使う亜種

一方で、unique symbolを使うやりも、冒頭で示した1種類だけではありません。他にもやり方が考えられています。

一応紹介しますが、筆者としては、冒頭で紹介したものが若干優位だと考えています。その理由は、この記事で述べたような「嘘」をunique symbolの力で「カプセル化」することについては冒頭のやり方が最も優れています。

そのため、以下で紹介するようなやり方は、unique symbolを使わないものに比べればマシですが、強い理由がなければ採用しなくてもいいと思います。

ブランドを表すシンボルを1つにする

const brand = Symbol();
type UserId = string & { [brand]: "UserId" };
type PostId = string & { [brand]: "PostId" };

unique symbolをキーとして活用しつつ、値部分の型でブランドを区別する方法です。

この方法だと、複数モジュールでBranded Typeを作りたい場合にbrandを複数のモジュール間で共有しなければならずexportが必要となってしまい、カプセル化のメリットが薄れてしまうので若干おすすめ度が低いです。

また、複数のブランドに属するBranded Typeを作れないという問題もあります。つまり、UserId & PostIdとして「ユーザーIDであり記事IDでもある文字列」を表せるかどうかという問題です。このユースケースではそんな型を考えないでしょうが、Branded Typeをバリデーションのために使うユースケースでは問題となります。

つまり、次のような場合です。

const brand = Symbol();
type NonEmptyString = string & { [brand]: "NonEmptyString" };
type LessThan100CharactersString = string & { [brand]: "LessThan100Characters" };

type GoodString = NonEmptyString & LessThan100CharactersString;

この定義だと、GoodStringneverになってしまい合成できていません。

ブランドごとに個別にシンボルを使用する定義であれば合成できます、

const nonEmptyBrand = Symbol();
const lessThan100CharactersBrand = Symbol();

type NonEmptyString = string & { [nonEmptyBrand]: unknown; };
type LessThan100CharactersString = string & { [lessThan100CharactersBrand]: unknown };
// 合成できる!
type GoodString = NonEmptyString & LessThan100CharactersString;

シンボル1つのままで合成の問題を解決する

ただし、シンボル1つのままで合成可能にする方法もあります。それは、共通のシンボルの中をオブジェクトにする方法です。

const brand = Symbol();

type NonEmptyString = string & { [brand]: { NonEmpty: unknown } };
type LessThan100CharactersString = string & { [brand]: { LessThan100Characters: unknown } };

type GoodString = NonEmptyString & LessThan100CharactersString;

この方法では、カプセル化の問題は良くなっていないものの、合成の問題は無くなります。

そこまでしてこのやり方を取っている例がEffect TSのドキュメントで見つかります。この例では、Brand<K>という型を作っています。

記事から引用
const BrandTypeId: unique symbol = Symbol.for("effect/Brand")

interface Brand<in out K extends string | symbol> {
  readonly [BrandTypeId]: {
    readonly [k in K]: K
  }
}

この型を使うことで、Branded Typeを手軽に定義できます。

type NonEmptyString = string & Brand<"NonEmpty">;
type LessThan100CharactersString = string & Brand<"LessThan100Characters">;

このように、unique symbolをライブラリの中に隠して、利用者側はシンボルを意識せずにBrandを使えるようにしたい場合に、このやり方が有効です。

declare constを使う方法

これまでの例ではunique symbol型を得るためにSymbol()でシンボルを作成する方法を説明していました。

しかし、型上でしか使わないのにランタイムで実際にSymbol()を作るのはオーバーヘッドがある気がします。

その場合は、実際にシンボルを作らずにunique symbol型だけを得るためにdeclare const構文を使うこともできます。

declare const構文を使う
declare const userIdBrand: unique symbol;

export type UserId = string & { [userIdBrand]: unknown };

export function createUserId(rawId: string): UserId {
  return rawId as UserId;
}

これについては、筆者はどちらでもいいと考えています。Symbol()のオーバーヘッドは微々たるものだからそこまで意識しなくてもいいでしょうし、declare constはランタイムの実態がないのに変数を宣言できてしまうので若干危険な構文なので、絶対に使わなければならないというほどではないでしょう。

とはいえ、declare constで余計なオーバーヘッドを消せるのも事実ですから、気になる場合はこちらを採用してもいいでしょう。

まとめ

この記事では、TypeScriptのBranded Typeの定義について、筆者がベストプラクティスだと考えるやり方を紹介し、他のやり方と比較しながら理由を説明しました。

Branded Typeのベストプラクティス
const userIdBrand = Symbol();

export type UserId = string & { [userIdBrand]: unknown };

export function createUserId(rawId: string): UserId {
  return rawId as UserId;
}

シンボルは、TypeScriptにおけるカプセル化の道具として特に有用です。Branded Typeもその活用例と考えることができます。臆せずシンボルを活用していきましょう。

21
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
21
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?