Branded Typeについては、もういろんなところで触れられているから、わざわざ書かなくてもいいよなぁという気持ちがありつつ。でも、せっかく頭の整理をしたから、来月の自分用にまとめておくくらいはやっておこうか、という気持ちになったのでメモを残しておく。それとZodの.brand
。
やりたいこと
次の2つの型に対する値を間違えて渡したときに、TypeScriptの型検査でエラーになってほしい。
type UserId = number; type BookId = number;
TSの型システムはStructural Subtypingを採用しているので、構造が同じだったら部分型として扱われる。だから、↓こんな風にUserId
を引数で受け取る関数にBookId
の値を渡してもエラーにならない。どちらも同じnumber
型だから。
const getUser = (id: UserId) => ({ id, name: 'SHIIBA' }); // 例なので適当 const bookId: BookId = 1; const user = getUser(bookId); // UserId型の引数に対してBookId型の変数を渡してもエラーにならない
先に言っておくと
先に言っておくと、Branded Typeをいたるところで使いたいとは思っていない。それなら最初からJavaとかを使えばいいやん、と思う(フロントエンドTSじゃなくてサーバーサイドTSの頭で喋っている)。
TSを使うからにはTSの良さ・柔軟さを大切にしたいので、普通に書いても大丈夫な部分はできるだけ普通に書きたい。でも「ここは型で守っておきたいな」という部分にはBranded Typeという選択肢もあるよなって、引き出しに入れておこうという気持ち。
Branded Type
ということでUserId
とBookId
を区別するのにBranded Typeと呼ばれる手法が使える。↓こんな感じでマーカーとなる型をIntersection Typeでくっつける
type UserId = number & { __brand: 'UserId' }; type BookId = number & { __brand: 'BookId' };
そうするとUserId
とBookId
が異なる構造になるので区別されるようになる。
const bookId = 1 as BookId; const user = getUser(bookId); // UserId型の引数に対してBookId型の変数を渡すとエラーになる
Branded Typeの仕組みはこれだけ。シンプル。
オブジェクト型でも同じ
ここまでプリミティブ型を例にしてBranded Typeを説明したけど、オブジェクト型でも同じことが言える。
↓元の構造は同じだけどBranded Typeにしてるから別の型として扱われる。
type Cat = { name: string; age: number } & { __brand: 'Cat' }; type Dog = { name: string; age: number } & { __brand: 'Dog' };
Intersection Typeとは?
Intersection Typeを使ってブランドの型をつけている・・・とはどういうことなのか。Intersection Typeについては↓が分かりやすい。
type XY = { x: number; y: number; }; type Z = { z: number; }; // これがIntersection Type type XYZ = XY & Z; const p: XYZ = { x: 0, y: 1, z: 2, };
Intersection TypeであるXYZ
はXY
とZ
の両方のプロパティを持った型になる。今回のCat
とDog
の例だと↓こんな構造になる。
type Cat = { name: string; age: number; __brand: 'Cat'; }; type Dog = { name: string; age: number; __brand: 'Dog'; };
プリミティブも同じように考えたらOK。でも、ちょっと違う部分もあるから、それについては後で書く。
いくつか気になる点
これでUserId
とBookId
、Cat
とDog
が区別できるようになって嬉しい!のだけど、今の実装ではいくつか気になる点がある。
- [気になり1]
__brand
プロパティにアクセスできてしまう - [気になり2] そもそもオブジェクトの場合は
__brand
プロパティがすでに存在する可能性がある - [気になり3] 間違えて同じリテラル型を指定すると同じ構造として扱われてしまう
気になり1と2
今の実装だと__brand
プロパティにアクセスできてしまう。でも、__brand
は型の区別が目的なので、できれば通常のプロパティとしてアクセスできるようにはしたくない。さらに、オブジェクト型に対してプロパティ名が重複する可能性も避けたい。
だからSymbolとcomputed property nameを使う。
const brand = Symbol('My Brand Property'); type Cat = { name: string; age: number } & { [brand]: 'Cat' }; type Dog = { name: string; age: number } & { [brand]: 'Dog' };
これによって、brand
変数を使わなければプロパティにアクセスできなくなる。さらに、Brand化対象オブジェクトのプロパティと重複する心配もない。
気になり3
今は'Cat'
や'Dog'
のように文字列リテラルで型を指定しているので、誤って同じリテラルを指定してしまうと型が区別されない。できればこの可能性もなくしておきたい。
だからブランドプロパティの型として'Cat'
などの文字列リテラルを使うんじゃなくて、unique symbolを使うようにする。型の情報だけあればいいのでdeclareで宣言すればいい。
const brand = Symbol('My Brand Property'); declare const catBrand: unique symbol; type Cat = { name: string; age: number } & { [brand]: typeof catBrand }; declare const dogBrand: unique symbol; type Dog = { name: string; age: number } & { [brand]: typeof dogBrand };
ただ、これについては、どうなんだろうな?という気持ちもある。いろんなサイトの例では文字列リテラルをよく見かける。重複しないような仕組みにして管理できるなら文字列リテラルのほうが分かりやすいのかもしれない。
できた・・・あとはasが気になる
とりあえずできた。これでCat
とDog
は構造が同じだけど型としては区別されるし、細かい気になりも解決した。
const brand = Symbol('My Brand Property'); declare const catBrand: unique symbol; type Cat = { name: string; age: number } & { [brand]: typeof catBrand }; declare const dogBrand: unique symbol; type Dog = { name: string; age: number } & { [brand]: typeof dogBrand }; const playWithCat = (cat: Cat) => { // Catと遊ぶ }; const cat = { name: 'タマ', age: 2 } as Cat; playWithCat(cat);
トランスパイルを実行すると型の情報は消えるので↓こんな風になる。実行時に不要な情報はほぼ消えていてbrand
だけが残っている状態。良い。
const brand = Symbol('My Brand Property'); export const playWithCat = (cat) => { // Catと遊ぶ }; const cat = { name: 'タマ', age: 2 }; playWithCat(cat);
あと気になるのはasを使ってる部分だけだな。
const cat = { name: 'タマ', age: 2 } as Cat;
毎回asを書くとどこかでミスをしそうなのと、データのバリデーションもしておきたいので「バリデーションを実行してCat
を返す関数」を作る。
type CatInput = { name: string; age: number }; const Cat = (input: CatInput) => { if (!input.name) { throw new Error('name is required'); } if (input.age < 0) { throw new Error('age should be positive'); } return input as Cat; }; const cat = Cat({ name: 'タマ', age: 2 });
こんな感じかなー。
からのZod
Zodについては昨日ドキュメントをザーッと読んだ。
その中に.brand
がある。.brand
をつけてスキーマを定義しておくと、parse
したあとに生成されたデータの型がBrand化されたものになる、という機能。
Zodを使って↑のコードを書き換えるとこうなる。Catだけ書く。
import { z } from 'zod'; // ブランドの識別につかうunique symbol declare const catBrand: unique symbol; const catSchema = z // バリデーション .object({ name: z.string().nonempty(), age: z.number().int().gte(0), }) // パース後にブランド化する .brand<typeof catBrand>(); type CatInput = z.input<typeof catSchema>; type Cat = z.output<typeof catSchema>; const Cat = (input: CatInput) => catSchema.parse(input); const playWithCat = (cat: Cat) => { // Catと遊ぶ }; const cat = Cat({ name: 'タマ', age: 2 }); playWithCat(cat);
これで「Zodのバリデーションに成功したものだけがCatになれる」ということが実現できた。便利。
ZodではBranded Typeをどう実装してるんだろう?
というのが気になったのでZodの実装を見てみた。ZodのBRAND型はここにある。
↓こんな風に定義してあって、parse後はこの型のIntersection Typeになる。
export const BRAND: unique symbol = Symbol("zod_brand"); export type BRAND<T extends string | number | symbol> = { [BRAND]: { [k in T]: true }; };
やっていることは次のとおり。
まず、Symbol("zod_brand")
でunique symbolを生成して、そのcomputed property nameを使っている。
そのプロパティの型を、先ほどの僕の実装ではcatBrand
というこれもまたunique symbolを直接指定していたが、Zodの実装ではオブジェクト型になっている。渡された文字列 or 数値 or シンボルがキーでその型がtrue
のオブジェクト。
Effectの実装も同じようにオブジェクトになっていた。違いは、型がtrue
じゃなくてキーに渡したものになってるくらい。キーが違えば異なる構造になるから、キーに対する型は何でもいいんだろうな。
オブジェクト型にしてるのは、そうしておくとBrandとBrandのIntersection Typeも定義できるから、そういう意図なのかな。
プリミティブ型のIntersection Typeについて
最後に、プリミティブ型のIntersection Typeについて触れて終わりにする。『サバイバルTypeScript』にも書いてあるとおりプリミティブ型同士のIntersection Typeはnever
になる。
じゃあ、プリミティブ型とオブジェクト型のIntersection Typeも同じようにnever
になりそうなものだけど、そうはならない。
たとえば↓こんな風にA
を定義してもA
はnever
にはならずに、number
と{ hello: string }
の両方の性質を持った型になる。
type A = number & { hello: string }; const a: A = 1 as A; // numberとして演算をしたり console.log(a + 1); // 2になる // helloプロパティにアクセスしたりできる console.log(a.hello); // 今回の場合 undefined になる
両方の性質を実際に持った値は普通は作れないけど、Number
クラスのprototype
やインスタンスの__proto__
を触れば作れる。
const a: A = 1 as A; // こんな風にやれば作れる // @ts-ignore a.__proto__.hello = 'world'; console.log(a + 1); // 2になる console.log(a.hello); // worldになる
prototype
を触れば技術的には実現できるからプリミティブ型とオブジェクト型のIntersection Typeはnever
にはならないんだろうな。と思った。おかげで、プリミティブに対してもBranded Typeが機能するのでありがたい話だ。
おわり
ということで、Branded TypeとZodの.brand
について勉強したことのまとめ終わり。