🐱

TypeScriptでオブジェクトの生成時にプロパティ全てにBrandedTypesを付与する関数を作成する

2023/06/23に公開
1

結論

export const withBrand = <T extends { [k: string]: unknown }, B extends string>(obj: T, brand: B) => {
  return obj as Record<keyof T, T[keyof T] & { __Brand: typeof brand }>;
};

解説

型パラメータ <T extends { [k:string] unknown }, B extends string>

ここでは2つの型パラメータ T と B を定義しています。T はオブジェクトの型を受け入れます。B はブランド(一意の識別子)の型を表します。

関数パラメータ (obj: T, brand: B)

関数には2つのパラメータがあります。obj はブランドを付けるオブジェクト、brand はブランドそのものです。

関数の戻り値 Record<keyof T, T[keyof T] & { __Brand: B }>

この関数は、元のオブジェクトのプロパティを保持しつつ、各プロパティにブランドプロパティ __Brand を追加した新しいオブジェクトを返します。この __Brand プロパティは元のブランド brand の型 typeof brand を持ちます。
型パラメータ T と B を使用していますが、これらは関数のパラメータ obj と brand から直接推論されます。

この関数が役立つ状況

この関数は、特定のオブジェクトが特定の役割を果たすことを型システムに通知する場合に役立ちます。例えば、ある関数が特定のブランドのオブジェクトのみを引数として受け入れるようにすることができます。

以上!

Discussion

nap5nap5

これ便利ですね。ぼくもちょっとデモつくってみました。

https://codesandbox.io/p/sandbox/jovial-ptolemy-nwlrkv?file=%2Fsrc%2Findex.ts%3A7%2C16

定義側

import { match } from "ts-pattern";
import { Entries } from "type-fest";
import { dequal } from "dequal";

// @see https://zenn.dev/sho_ts/articles/9dcd30f593c0f3
export const withBrand = <T extends {}, B extends string>(obj: T, brand: B) =>
  obj as Record<keyof T, T[keyof T] & { __Brand: B }>;

const UploadStatusPattern = {
  INITIAL: {
    name: "INITIAL",
    value: 1,
  },
  PREPARED: {
    name: "PREPARED",
    value: 2,
    salt: "hoge",
  },
  UPLOADING: {
    name: "UPLOADING",
    value: 3,
  },
  UPLOADED: {
    name: "UPLOADED",
    value: 4,
  },
  UPLOAD_ERROR: {
    name: "UPLOAD_ERROR",
    value: 5,
  },
} as const;

const UploadStatusData = (
  Object.entries(UploadStatusPattern) as Entries<typeof UploadStatusPattern>
).map(([key, data]) => data);

type UploadStatus = (typeof UploadStatusData)[number];

export const getCurrentUploadStatus = (uploadStatus: UploadStatus) => {
  return match(uploadStatus)
    .with({ name: "INITIAL" }, (data) => data)
    .with({ name: "PREPARED" }, (data) => data)
    .with({ name: "UPLOADING" }, (data) => data)
    .with({ name: "UPLOADED" }, (data) => data)
    .with({ name: "UPLOAD_ERROR" }, (data) => data)
    .exhaustive();
};

export const UPLOAD_STATUS = withBrand(UploadStatusPattern, "UploadStatus");

type BrandedUploadStatus = (typeof UPLOAD_STATUS)[keyof typeof UPLOAD_STATUS];

export const isUploading = (uploadStatus: BrandedUploadStatus) =>
  dequal(uploadStatus, UPLOAD_STATUS.UPLOADING);

使用側

import { test, expect } from "vitest";
import { UPLOAD_STATUS, getCurrentUploadStatus, isUploading } from ".";

test("[1]", () => {
  const result = isUploading(UPLOAD_STATUS.UPLOADING);
  expect(result).toStrictEqual(true);
});

test("[2]", () => {
  const result = isUploading(UPLOAD_STATUS.PREPARED);
  expect(result).toStrictEqual(false);
});

test("[3]", () => {
  const result = getCurrentUploadStatus(UPLOAD_STATUS.PREPARED);
  if (result.name === "PREPARED") {
    expect(result.salt).toStrictEqual("hoge");
  }
});

簡単ですが、以上です。