🌊

Opaque型を使ってID<Entity>型をTypeScriptで実装する

2022/06/26に公開
1

とある技術系の発表を見ていて ID<T> という型を目にしました。使用例は以下になります。

使用例
interface UserRepository {
  findById: (userId: ID<User>) => Promise<User>
}

良い型だなと思いました。これまで自分は同様のコードを書く場合、 UserId といった ValueObject を使って次の様に記載していました。

これまで
interface UserRepository {
-  findById: (userId: ID<User>) => Promise<User>
+  findById: (userId: UserId) => Promise<User>
}

細かい点ではありますが、ID<User>UserId を比べると次のメリットがあります。

  • Entity に対する ID 指定に一貫性が保てることで、可読性が高くなる
  • 集約(User)を import すれば良く、ValueObject(UserId)を import しなくて良い
  • ID(識別子)に対して共通の関心がある場合、表現しやすくなる

この ID<T> 型(T は id を持った Entity に相当する型)を TypeScript で実装してみます。

型の定義

素直に実装すると次の様になりました。

type ID<T extends { id: string }> = T["id"];

OOP で実装する場合は { id: string }class BaseEntity {} の様な型になります。

この実装は悪くないのですが、一点問題があります。それは次の様な実装の場合に型を判別せず、型エラーが発生しないことです。

type UserId = string;
type TaskId = string;

interface User {
  id: UserId;
  name: string;
}

interface Task {
  id: TaskId;
  name: string;
}

function userIdLog(userId: ID<User>) {
  console.log("ID:", userId)
}

const userId: ID<User> = validateUserId('userid')
const taskId: ID<Task> = validateTaskId('taskid')

userIdLog(userId)
userIdLog(taskId) // ID<User>型の引数にTaskId型が与えられているがエラーにはならない

playground

構造的部分型と公称型

前述のエラーは TypeScript の構造的部分型(structural subtyping)という仕様が関連しています。詳細はサバイバル TypeScript の説明がわかりやすいので、そちらを参考にしてください。

https://typescriptbook.jp/reference/values-types-variables/structural-subtyping

上記の中でも紹介されていますが、公称型(nominal typing)を使うことでこの問題を解消できます。公称型を TypeScript で表現する方法はいくつかあります。次の記事の解説が分かりやすいです。

https://blog.beraliv.dev/2021-05-07-opaque-type-in-typescript

今回は公称型の実装の 1 つである Opaque 型というテクニックを使ってみます。次の記事を参考にしています。

https://qiita.com/k-penguin-sato/items/0adb0d9df35d96d04b1c

Opaque 型での実装

前章で紹介した記事を参考に次の様な型を定義します。

declare const opaqueSymbol: unique symbol;

type Opaque<T, U = string> = U & { readonly [opaqueSymbol]: T }

また、Primitive な型を ValueObject に型変換させる関数も定義します。

type UserId = Opaque<'UserId'> 
type TaskId = Opaque<'TaskId'>

function validateUserId(input: string) {
  // ここに文字数制限などの条件を記載する。本記事の趣旨ではないため割愛
  return input as UserId;
}

function validateTaskId(input: string) {
  // 同上
  return input as TaskId;
}

以上により、最初の例で無視されたエラーがしっかりと指摘される様になります。

declare const opaqueSymbol: unique symbol;

type Opaque<T, U> = T & { readonly [opaqueSymbol]: U }

type UserId = Opaque<string, 'UserId'> 
type TaskId = Opaque<string, 'TaskId'>

function validateUserId(input: string) {
  return input as UserId;
}

function validateTaskId(input: string) {
  return input as TaskId;
}

interface User {
  id: UserId;
  name: string;
}

interface Task {
  id: TaskId;
  name: string;
}

type ID<T extends { id: string }> = T["id"]

function userIdLog(userId: ID<User>) {
  console.log("ID:", userId)
}

const userId: ID<User> = validateUserId('userid')
const taskId: ID<Task> = validateTaskId('taskid')

userIdLog(userId)
userIdLog(taskId) // Error !!

playground

まとめ

TypeScript で ID<T> を Opaque 型を使い実装してみました。最近は DDD を実践するにしてもより薄く、シンプルに実装できる方法というのに興味があり、色々と実験をしています。Opaque 型のテクニックを知ったのは結構前だったのですが、その際に構造的部分型や公称型のことについて学びました。OOP でクラスベースの ValueObject を実素していると、構造的部分型による今回の問題を意図せず回避していることに気付きました。TypeScript の仕様を理解し実践することで選択肢が広がり、同じ結果を得る場合でもより適した実装を選択できる様になるのではないかと考えています。

GitHubで編集を提案

Discussion

nap5nap5

少し脱線するかもですが、型のRemapping ようなものがあるらしく、それでIDを表現してみました。IDに別名をつける際のワークアラウンドになります。

export type User = {
  id: number;
  name: string;
};

export type Users = User[];

// https://www.typescriptlang.org/docs/handbook/2/mapped-types.html#key-remapping-via-as
export type UserID = {
  [Property in keyof Pick<User, "id"> as `user${Capitalize<
    string & Property
  >}`]: Pick<User, "id">[Property];
};

const someone: UserID = {
  userId: 37458,
};

type Session = {
  token: string;
};

type NeatType = Simplify<UserID & Session>;
type NeatType2 = Simplify<Session>;

interface UserRepository {
  findById: ({ userId, token }: NeatType) => Promise<User>;
  listUp: ({ token }: NeatType2) => Promise<Users>;
}

class UserFactory implements UserRepository {
  async findById({ userId, token }: NeatType): Promise<User> {
    throw new Error("Cowboy");
  }
  async listUp({ token }: NeatType2): Promise<Users> {
    throw new Error("Bebop");
  }
}

const userFactory = new UserFactory();

console.log(someone);

const result = userFactory.findById({ token: "xxx", userId: 37458 });
const result2 = userFactory.listUp({ token: "xxx" });

デモコードです。
https://codesandbox.io/p/sandbox/little-wave-jbihjb?file=%2FREADME.md

簡単ですが、以上です。