TypeScript と Opaque型で電話番号型を定義する

1. はじめに

カラダノートの 堀内 です。

TypeScript の型定義ライブラリの1つである type-fest を使って、電話番号型 を作って実験したメモです。

github.com

2. Opaque型について

2.1 実装の種類

TypeScriptでは公式にOpaque型をサポートしていないので、色々ググってみると現状以下の呼び方と実装があるようです。

  • Nominal型(公称型)
  • Opaque型(不透明型)

今回は type-festのOpaque型を使いましたが、utility-types ライブラリのNominal型でも同じことが実現できます。

というわけでOpaque型で実装してみます。

2.2 電話番号型の実装

今回は以下のように実装しました。

import { Opaque } from 'type-fest'

// 電話番号になりうる基底型
type BaseType = string

// 電話番号型
type PhoneNumber = Opaque<BaseType, 'PhoneNumber'>

// 電話番号型になりうるかの検証
const validate = (value: BaseType): value is PhoneNumber => {
  // 半角数字1文字以上を電話番号とみなす
  return /^[0-9]+$/.test(value)
}

interface CreateValueObject<Base, T> {
  (value: Base): T
}

const PhoneNumber: CreateValueObject<BaseType, PhoneNumber> = (value) => {
  if (!validate(value)) {
    throw new Error(`${value}は電話番号ではありません`)
  }
  return value  // as PhoneNumber としなくても PhoneNumber型が返る
}

export default PhoneNumber

2.3 実装した電話番号型の使い方

以下のように使用します。

import PhoneNumber from './phoneNumber'

const phoneNumberA = PhoneNumber('0120123456')
const phoneNumberB = PhoneNumber('08098765432')

console.log(phoneNumberA == phoneNumberB)
// false

2.4 Classでの電話番号型の実装

同じことはClassを使っても実現できます。例えば以下のように実装します。

export default class PhoneNumberClass {
  constructor(readonly value: string) {
    if (!this.validate(value)) {
      throw new Error(`${value}は電話番号ではありません`)
    }
  }

  validate(value: string) {
    // 半角数字1文字以上を電話番号とみなす
    return /^[0-9]+$/.test(value)
  }

  equals(other: PhoneNumberClass) {
    return this.value == other.value
  }
}

3. Opaqueの電話番号型とClassの電話番号型の違い

ここからは2つの実装の違いについて、色々実験してみます。

3.1 JSON文字列への変換

たとえば以下のようにuserAとuserBを作ってJSON文字列にしてみます。

  • userA: Opaqueで実装した電話番号を使用
  • userB: Classで実装した電話番号を使用
const userA = {
  name: 'karada',
  phoneNumber: PhoneNumber('0123456789')
}

const userB = {
  name: 'note',
  phoneNumber: new PhoneNumberClass('0123456789')
}

console.log(JSON.stringify(userA))
console.log(JSON.stringify(userB))
{"name":"karada","phoneNumber":"0123456789"}
{"name":"note","phoneNumber":{"value":"0123456789"}}

上記のとおり、Classで実装したほうはそのまま使うと{ "value": "xxx" } になってしまいます。

3.2 Jsonオブジェクト型への設定

以下のようなJSONオブジェクト型を定義してプロパティに電話番号を設定してみます。

type Json =
  | string
  | number
  | boolean
  | null
  | { [property: string]: Json }
  | Json[]

const userA: Json = {
  name: 'karada',
  phoneNumber: PhoneNumber('0123456789')
}

const userB: Json = {
   name: 'note',
   phoneNumber: new PhoneNumberClass('0123456789')
}

すると、userBのほうは型チェックでコケます。Json型はプリミティブのみサポートする型定義をしているので。

f:id:karadanote:20210331085256p:plain

3.3 ORMを使ったDBへの登録

同様に以下のような schema.prisma で定義されているDBに対して、Prisma Client を使ってデータを登録してみます。

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model User {
  id    Int     @id @default(autoincrement())
  name  String?
  phoneNumber   String?
}

f:id:karadanote:20210331085415p:plain

このケースも同様にコケます。Prismaが生成するテーブルのカラムの型定義もプリミティブになっているので。

まとめると

Opaqueで実装した電話番号型では、JSON化したりDBに登録したりするサービスを実装するときに、プリミティブ型と同じように扱える、ということです。

もちろん、そもそもの Opaque型の利点としては、PhoneNumberを単にstringのエイリアスと定義しないことで、以下のような型チェックの恩恵を受けられることにあります。

f:id:karadanote:20210331001651p:plain

f:id:karadanote:20210331001702p:plain

他にもOpaqueでできることがあれば試してみたいですね。

Appendix

他の実装パターンとして以下の実装も参考にしました。

また、参考図書として、こちらの4章 ~ 6章が特に参考になります。

www.oreilly.co.jp