📝

branded types を使って型レベルで変数を説明しよう!

に公開

はじめに

こんにちは!
最近は就活から逃げてニッチな技術系イベントに参加し、企業の方に「学生なのにこんなイベントに来ていて偉いね」と褒められることが生きがいの 27 卒学生エンジニアの yossuli です。
本記事は #Progate_Bar での発表内容を記事にしたものです。
今回は僕が個人開発で大規模なフォームを開発する際に、 refine を使って各項目間での依存関係を持たせる制約をつける際に、各変数がどのフィールドのものであるかがわからず困った際に、branded types を使って解決できたので、その理由と具体例を紹介します。

branded types とは?

branded types とは、型に「ブランド」をつけることで、同じ型であっても異なる意味を持たせることができる TypeScript の機能です。
例えば、以下のように UserIdPostId という branded types を定義することができます。

type UserId = string & { readonly userId: unique symbol };
type PostId = string & { readonly postId: unique symbol };

なぜ branded types を使うべきか?

上記のように定義することで、UserIdPostId はどちらも string 型ですが、それぞれが user ID と post ID という異なる意味を持つことができます。
そして、UserIdPostId は互換性がないため、間違って UserIdPostId として誤って使用することを防ぐことができます。

const userId: UserId = "user123" as UserId; // OK
const postId: PostId = "post456" as PostId; // OK
const invalidId: UserId = postId; // エラー: Type 'PostId' is not assignable to type 'UserId'.

このようにして静的解析の段階で誤った変数の使用を防ぐことができます。

具体例

僕はプロダクトに zod を導入しているので以下のように branded types を定義しました。

import { z } from "zod";
const UserId = z.string().brand("UserId");
const PostId = z.string().brand("PostId");

このようにしておくと、zod のスキーマを定義する際に branded types を使うことができます。

const userSchema = z.object({
  id: UserId,
  name: z.string(),
});
const postSchema = z.object({
  id: PostId,
  title: z.string(),
  content: z.string(),
  authorId: UserId,
});

また、どのようなブランドで縛るかを別の定数で持っておくことで、間違って同じブランドで縛ってしまうといったことも防いでいます。
すでにこのことについては

https://zenn.dev/yossuli/articles/eb3e471d954c15

こちらの記事で言及しているので、詳細はそちらを参照してください。

constants.ts
const BRAND_NAMES = noDuplicate([
  "UserId",
  "PostId",
] as const);
branded.ts
const branded = <
  T extends z.ZodTypeAny,
  U extends (typeof BRAND_NAMES)[number]
>(
  schema: T,
  name: U
) => schema.brand(name);
schema.ts
const userSchema = z.object({
  id: branded(z.string(), "UserId"),
  name: z.string(),
});
const postSchema = z.object({
  id: branded(z.string(), "PostId"),
  title: z.string(),
  content: z.string(),
  authorId: branded(z.string(), "UserId"),
});

もし noDuplicate に渡している配列に重複がある場合、TypeScript のコンパイル時にエラーが発生します。

const BRAND_NAMES = noDuplicate([
  "UserId", // 型 '"UserId"' を型 'never' に割り当てることはできません。
  "PostId",
  "UserId", // 型 '"UserId"' を型 'never' に割り当てることはできません。
] as const);

まとめ

branded types を使うことで、型レベルで変数の意味を明確にし、誤った使用を防ぐことができます。
特に大規模なフォームなど、複雑な Schema を扱う場合において、各フィールドの依存関係を定義する際に非常に役立ちます。
皆さんもぜひ型をがちがちに固めて開発体験を向上させましょう!
最近は型パズルにはまっており、それ関連で記事をいくつか執筆していますのでご興味のある方はぜひご覧いただければなと思います。

ちなみに、本記事以外にも 4 本、今回の LT に関連して記事を挙げていますのでそちらも併せてご覧いただけると幸いです。
xのフォローもよろしくお願いします!

Discussion