TypeScript: 「電話番号型」という意味を持たせた特殊なstring型の作り方

この投稿では、値としては「文字列」なんだけど、単なる文字列ではなく「電話番号型」という意味を持たせた文字列型を定義し、それ使用する方法を紹介します。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">TypeScriptで「電話番号型」みたいな、正規表現でバリデーションされるような型は作れるんかな？<br><br>ElmだとOpaque Typeなんてやり方があったけど。。。<br>用は型をexportしないで、その型の値を作る方法だけをexportすればええんかな。</p>&mdash; 無職やめ太郎（本名） (@Yametaro1983) <a href="https://twitter.com/Yametaro1983/status/1253319696205541379?ref_src=twsrc%5Etfw">April 23, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

↑上のような疑問に対する答えです。

## 実現方針

方針としては、以下のテクニックの組み合わせです。

* 公称型で、「電話番号型」を定義する
* ユーザ定義タイプガードで、文字列型を電話番号型としてコンパイラに認識してもらう

公称型とその実装方法についての基本は下記投稿をご覧ください。

* [TypeScript: 異なる2つの型システム「公称型」と「構造的部分型」 - Qiita](https://qiita.com/suin/items/52cf80021361168f6b0e)
* [公称型をTypeScriptで実現するための基礎 - Qiita](https://qiita.com/suin/items/ae9ed911ebab48c98835)

ユーザ定義タイプガードについては下記ページが参考になります。

* [Advanced Types · TypeScript](https://www.typescriptlang.org/docs/handbook/advanced-types.html#user-defined-type-guards)
* [Type Guard - TypeScript Deep Dive 日本語版](https://typescript-jp.gitbook.io/deep-dive/type-system/typeguard#yznotype-guard)

## 具体的な実現方法

### 公称型で「電話番号型」を定義する

ここでは、公称型のテクニックを使って、電話番号型を定義する方法を説明します。

先に、これから定義する電話番号型の特徴を決めておきます:

* `string`型は電話番号型に代入できない。つまり、2つの型は区別される。
* 電話番号型は`string`型に代入できる。つまり、電話番号型は文字列として扱うことができる。

それでは、実際にこの特徴を満たす型を定義していきます。

まず、電話番号型として`PhoneNumber`という型エイリアスを定義します。

```typescript
type PhoneNumber = string
```

これは`string`型に`PhoneNumber`という別名をつけただけです。なので、このままだと、`string`と`PhoneNumber`をコンパイラは区別しません。相互に代入可能です。

```typescript
let a: string = '123'
let b: PhoneNumber = '456'
a = b // OK
b = a // OK
```

このままではダメなので、`PhoneNumber`型を公称型にします。公称型にするテクニックの基礎は下記投稿で説明されているので詳細は省きますが、

* [公称型をTypeScriptで実現するための基礎 - Qiita](https://qiita.com/suin/items/ae9ed911ebab48c98835)

ざっくり言うと、シンボルを持ったインターフェイスと`string`型の交差型(intersection type)を定義して公称型を実現します:

```typescript
declare const phoneNominality: unique symbol
type PhoneNumber = string & { [phoneNominality]: never }
```

これで、コンパイラが`string`型と`PhoneNumber`型をしっかり区別してくれるようになります:

```typescript
let a: string = '123'
let b: PhoneNumber = '456' as PhoneNumber

a = b // OK
b = a // Error: Type 'string' is not assignable to type 'PhoneNumber'.
```

ちなみに、`PhoneNumber`の定義に使用した`declare const phoneNominality`や`type PhoneNumber =...`などの型レベルプログラミングコードは、コンパイル後のJavaScriptコードからは綺麗サッパリ消え去り、JavaScriptの振る舞いを変更するような処理は残らないので安心してください。下記コードを見ての通り、`PhoneNumber`型はJavaScript実行時はごくごく普通の文字列として扱われます:

```typescript
// コンパイル前コード
declare const personNominality: unique symbol
type PhoneNumber = string & { [personNominality]: never }
let a: string = '123'
let b: PhoneNumber = '456' as PhoneNumber

// コンパイル後コード
"use strict";
let a = '123';
let b = '456';
```

以上で、文字列型と区別できる「電話番号型」の定義の完成です。

最後に、電話を掛ける関数`callPhone`を書いて、実際に電話番号型だけを受け付けるか、コンパイルを試してみます。

```typescript
const callPhone = (phoneNumber: PhoneNumber): void => {
    console.log('%oに電話をかけてます……', phoneNumber)
}

const justString: string = '電話番号じゃないかも文字列'
const phoneNumber: PhoneNumber = '090-9999-0000' as PhoneNumber

callPhone(justString) // Error: Argument of type 'string' 
                      //   is not assignable to parameter of 
                      //   type 'PhoneNumber'.
callPhone(phoneNumber) // OK
```

`callPhone`に渡されたただの文字列`justString`はコンパイルエラーになるのに対し、電話番号型文字列の`phoneNumber`はエラーなく渡せているのが分かります。

### ユーザ定義タイプガードで、文字列型を電話番号型としてコンパイラに認識してもらう

続いて、ユーザ定義タイプガードを使って、文字列型の値をバリデーションした上で、その値を電話番号型として扱えるようにするコードを書いていきます。

最初のステップとして、文字列が電話番号かどうかチェックする関数を実装します:

```typescript
const isPhoneNumber = (value: string): boolean => {
    return /^[0-9]+-[0-9]+-[0-9]+$/.test(value)
}
```

この`isPhoneNumber`関数は、与えられた文字列が「数字-数字-数字」のフォーマットかを正規表現で判定し、フォーマットに合っていれば`true`を、そうでなければ`false`を返す関数です。(ちなみに、電話番号の正規表現としては厳密な実装ではないと思いますので、業務等で使う場合は、要件と仕様をよく検討してしかるべき正規表現を組んでください)

次に、上の`isPhoneNumber`をユーザ定義タイプガード関数にしたいので、戻り値の`boolean`をちょっと書き換えます:

```typescript
const isPhoneNumber = (value: string): value is PhoneNumber => {
    return /^[0-9]+-[0-9]+-[0-9]+$/.test(value)
}
```

この関数を`if`などで使い、チェックされた文字列は、その後の処理では型レベルで電話番号型と認識されるようになります:

```typescript
const wouldBePhoneNumber: string = '090-9999-0000'

// この時点では`string`型なのでエラーになる↓
callPhone(wouldBePhoneNumber) // Error: Argument of type 'string' 
                              //  is not assignable to parameter 
                              //  of type 'PhoneNumber'.

if (isPhoneNumber(wouldBePhoneNumber)) {
    // このスコープでは`PhoneNumber`型として解釈されるのでコンパイルエラーにならない↓
    callPhone(wouldBePhoneNumber) // OK
} else {
    console.log('%oは電話番号ではありません', wouldBePhoneNumber)
}
```

以上、TypeScriptで「電話番号型」という意味を持たせた特殊な`string`型の作り方でした。

## まとめ

最後に完成形の全体コードを載せておきます:

```typescript
declare const phoneNominality: unique symbol
type PhoneNumber = string & { [phoneNominality]: never }

const callPhone = (phoneNumber: PhoneNumber): void => {
    console.log('%oに電話をかけてます……', phoneNumber)
}

const isPhoneNumber = (value: string): value is PhoneNumber => {
    return /^[0-9]+-[0-9]+-[0-9]+$/.test(value)
}

const wouldBePhoneNumber: string = '090-9999-0000'

callPhone(wouldBePhoneNumber) // Error: Argument of type 'string' is not assignable to parameter of type 'PhoneNumber'.

if (isPhoneNumber(wouldBePhoneNumber)) {
    callPhone(wouldBePhoneNumber) // OK
} else {
    console.log('%oは電話番号ではありません', wouldBePhoneNumber)
}
```

このコードの[TypeScript Playgroundはこちら](https://www.typescriptlang.org/v2/en/play?#code/CYUwxgNghgTiAEYD2A7AzgF3gBwBapADkkBbASxSgjIwE8AueAVxTIEcmE1aSAjJCACg62BAAV8KIkz4gY8ALzxMMCgHN4AMngBveAG08BYuUrU6AXUZSAbnPgBfQYOTosYKhAkFF8ABRGUoQyvHKM3kEhcgCUjDZIZMCKAHy6gvAZiKhoAiAAdBBIan4A5ACkSIDWDIDfaYCOUYBJDIDSDICKDIBmDIB+DICaDIBkBN0lADQ4ktKyMNGCTi7ZWGRoESOh8kp+NlScjCrqsfCrEJzws-DzwaMpaZnwcBhMMCjwAPQAevoADAC0AJwWANRvr58-P7vL7fAAk9zyGBAmBWaxA40mrkw8AA7kgmBBgAAhEDHKIwDYYVQoDRKEovD7Aj7Ut4vOkvErODwQLzDPxojHY3HDE6LaIPe7wACiMBgSAJ8AAgjA1DIQCgsEgAGbwEQIEqbEklA5oeAoJBYKBoNBkNSUXgQBAYJA4WBQEggKHyZWq2iieAlPGjEp5ZxkFV+WZexbs9GYnHBmL8nTpTLM1kEUOciM8-H8+6CgDyAGkJvAQBA0AgYxckbkCkVShVAPYMdUAVq6Ad+VAOYM1cAQgyAKIZ2oBtBkAyQwDVFhrmRsYTIA)。

---

最後までお読みくださりありがとうございました。Twitterでは、Qiitaに書かない技術ネタなどもツイートしているので、よかったらフォローお願いします:relieved:→[Twitter@suin](https://twitter.com/suin)