TypeScript と Opaque型で電話番号型を定義する
1. はじめに
カラダノートの 堀内 です。
TypeScript の型定義ライブラリの1つである type-fest を使って、電話番号型
を作って実験したメモです。
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型はプリミティブのみサポートする型定義をしているので。
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? }
このケースも同様にコケます。Prismaが生成するテーブルのカラムの型定義もプリミティブになっているので。
まとめると
Opaqueで実装した電話番号型では、JSON化したりDBに登録したりするサービスを実装するときに、プリミティブ型と同じように扱える、ということです。
もちろん、そもそもの Opaque型の利点としては、PhoneNumberを単にstringのエイリアスと定義しないことで、以下のような型チェックの恩恵を受けられることにあります。
他にもOpaqueでできることがあれば試してみたいですね。
Appendix
他の実装パターンとして以下の実装も参考にしました。
また、参考図書として、こちらの4章 ~ 6章が特に参考になります。