🌊

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

2022/06/26に公開約5,900字

とある技術系の発表を見ていて 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

今回は公称型の実装の一つである 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

ログインするとコメントできます