TypeScriptのBranded TypeとZodの.brand

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

ということでUserIdBookIdを区別するのにBranded Typeと呼ばれる手法が使える。↓こんな感じでマーカーとなる型をIntersection Typeでくっつける

type UserId = number & { __brand: 'UserId' };
type BookId = number & { __brand: 'BookId' };

そうするとUserIdBookIdが異なる構造になるので区別されるようになる。

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であるXYZXYZの両方のプロパティを持った型になる。今回のCatDogの例だと↓こんな構造になる。

type Cat = {
  name: string;
  age: number;
  __brand: 'Cat';
};

type Dog = {
  name: string;
  age: number;
  __brand: 'Dog';
};

プリミティブも同じように考えたらOK。でも、ちょっと違う部分もあるから、それについては後で書く。

いくつか気になる点

これでUserIdBookIdCatDogが区別できるようになって嬉しい!のだけど、今の実装ではいくつか気になる点がある。

  • [気になり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が気になる

とりあえずできた。これでCatDogは構造が同じだけど型としては区別されるし、細かい気になりも解決した。

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については昨日ドキュメントをザーッと読んだ。

bufferings.hatenablog.com

その中に.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を定義してもAneverにはならずに、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について勉強したことのまとめ終わり。

参照