この投稿では、値としては「文字列」なんだけど、単なる文字列ではなく「電話番号型」という意味を持たせた文字列型を定義し、それ使用する方法を紹介します。
TypeScriptで「電話番号型」みたいな、正規表現でバリデーションされるような型は作れるんかな?
— 無職やめ太郎(本名) (@Yametaro1983) April 23, 2020
ElmだとOpaque Typeなんてやり方があったけど。。。
用は型をexportしないで、その型の値を作る方法だけをexportすればええんかな。
↑上のような疑問に対する答えです。
実現方針
方針としては、以下のテクニックの組み合わせです。
- 公称型で、「電話番号型」を定義する
- ユーザ定義タイプガードで、文字列型を電話番号型としてコンパイラに認識してもらう
公称型とその実装方法についての基本は下記投稿をご覧ください。
ユーザ定義タイプガードについては下記ページが参考になります。
具体的な実現方法
公称型で「電話番号型」を定義する
ここでは、公称型のテクニックを使って、電話番号型を定義する方法を説明します。
先に、これから定義する電話番号型の特徴を決めておきます:
-
string
型は電話番号型に代入できない。つまり、2つの型は区別される。 - 電話番号型は
string
型に代入できる。つまり、電話番号型は文字列として扱うことができる。
それでは、実際にこの特徴を満たす型を定義していきます。
まず、電話番号型としてPhoneNumber
という型エイリアスを定義します。
type PhoneNumber = string
これはstring
型にPhoneNumber
という別名をつけただけです。なので、このままだと、string
とPhoneNumber
をコンパイラは区別しません。相互に代入可能です。
let a: string = '123'
let b: PhoneNumber = '456'
a = b // OK
b = a // OK
このままではダメなので、PhoneNumber
型を公称型にします。公称型にするテクニックの基礎は下記投稿で説明されているので詳細は省きますが、
ざっくり言うと、シンボルを持ったインターフェイスとstring
型の交差型(intersection type)を定義して公称型を実現します:
declare const phoneNominality: unique symbol
type PhoneNumber = string & { [phoneNominality]: never }
これで、コンパイラがstring
型とPhoneNumber
型をしっかり区別してくれるようになります:
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実行時はごくごく普通の文字列として扱われます:
// コンパイル前コード
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
を書いて、実際に電話番号型だけを受け付けるか、コンパイルを試してみます。
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
はエラーなく渡せているのが分かります。
ユーザ定義タイプガードで、文字列型を電話番号型としてコンパイラに認識してもらう
続いて、ユーザ定義タイプガードを使って、文字列型の値をバリデーションした上で、その値を電話番号型として扱えるようにするコードを書いていきます。
最初のステップとして、文字列が電話番号かどうかチェックする関数を実装します:
const isPhoneNumber = (value: string): boolean => {
return /^[0-9]+-[0-9]+-[0-9]+$/.test(value)
}
このisPhoneNumber
関数は、与えられた文字列が「数字-数字-数字」のフォーマットかを正規表現で判定し、フォーマットに合っていればtrue
を、そうでなければfalse
を返す関数です。(ちなみに、電話番号の正規表現としては厳密な実装ではないと思いますので、業務等で使う場合は、要件と仕様をよく検討してしかるべき正規表現を組んでください)
次に、上のisPhoneNumber
をユーザ定義タイプガード関数にしたいので、戻り値のboolean
をちょっと書き換えます:
const isPhoneNumber = (value: string): value is PhoneNumber => {
return /^[0-9]+-[0-9]+-[0-9]+$/.test(value)
}
この関数をif
などで使い、チェックされた文字列は、その後の処理では型レベルで電話番号型と認識されるようになります:
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
型の作り方でした。
まとめ
最後に完成形の全体コードを載せておきます:
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はこちら。
最後までお読みくださりありがとうございました。Twitterでは、Qiitaに書かない技術ネタなどもツイートしているので、よかったらフォローお願いします→Twitter@suin