先日、異なる2つの型システム「公称型」と「構造的部分型」という投稿をして、TypeScriptの型システムは、JavaやPHPと全然違うという話をしました。
TypeScriptは「構造的部分型」と言って、オブジェクトの構造が同じなら互換したものとしてみなされるので、意味的に全く関係ないオブジェクト同士を入れ替えて扱うこともできるわけです。
例えば、たまたま同じname
プロパティを持っているBook
型とPerson
型があったとき、
type Book = { name: string }
type Person = { name: string }
TypeScriptではこの2つの型を区別しないんです。なので、Book
型の変数にPerson
型の変数を代入してもコンパイルエラーになりません。
const bob: Person = { name: 'Bob' }
const book: Book = bob
これは、JavaやPHPのような公称型の言語になれていると、気になってしまうところです。公称型のプログラミングでは、互換性を断ち切るため、つまり、誤った代入をしてしまわないために、わざわざ名前をつけるような側面もあるわけだからです。
本稿では公称型(nominal typing)をTypeScriptで実現するための基礎について説明していきたいと思います。
TypeScriptでも公称型を実現したい……!
TypeScriptの構造的部分型は柔軟性があってJavaScriptをあつかうには適切な型システムだと思いますが、一方で公称型のようにクラス名やインターフェイス名で厳密に区別されるような仕掛けが欲しくなることも、しばしばあるかと思います。
理想を言えば、nominal
修飾子のようなものがTypeScriptにあって、手軽に公称型を実現できたら良かったのですが。
type nominal Book = { name: string }
type nominal Person = { name: string }
現実は無いので型レベルプログラミングで努力することになります。
よくある公称型の実現方法
TypeScriptでの公称型のテクニックとしては、型ごとに微妙に構造を変えるというのが基本です。
hoge
でもfuga
でも何でもいいので、適当なプロパティを1つ追加して、全く同じ構造にならないようにするのがポイントです。
type Book = { name: string, hoge: any }
type Person = { name: string, fuga: any }
ただこれだと、うっかりプロパティ名がかぶって、同じ構造になってしまう事故が無いわけではないです。より安全にするために、シンボルを使うのが定石です。シンボルは絶対にかぶらないので安心できます。
const bookNominality = Symbol()
type Book = { name: string, [bookNominality]: any }
const personNominality = Symbol()
type Person = { name: string, [personNominality]: any }
上で定義した定数のシンボルは、実行時には不要なので、コンパイル後のJavaScriptコードに残らないよう、declare const
に置き換えてしまえば、実用レベルの実装にはなります。
declare const bookNominality: unique symbol
type Book = { name: string, [bookNominality]: any }
declare const personNominality: unique symbol
type Person = { name: string, [personNominality]: any }
最後に、万が一、
book[bookNominality] = '...'
のような誤った代入や、
return book[bookNominality]
のような誤った値の利用など、どのうっかりミスを防ぐためにも、シンボルの型をnever
にしておけばさらに安心です。
type Book = { name: string, [bookNominality]: never }
^^^^^
これで、book[bookNominality]
に代入しようとしたら即コンパイルエラーになり、book[bookNominality]
の値を参照してどこかに渡そうとしてもコンパイルエラーを起こしやすくなります。
どうやってBook
型の変数を作るの?
公称型チックなBook
型を定義することはできたのですが、その型の変数を作るにはどうしたらいいのでしょうか?
こうかな……?
type Book = { name: string, [bookNominality]: never }
const book: Book = { name: 'オライリー' }
これはコンパイルエラーになります。[bookNominality]
プロパティが足りないからです。
では、こう……?
const book: Book = { name: 'オライリー', [bookNominality]: undefined }
これもだめです。[bookNominality]
はnever
型なのでどんな値も代入できません。
やるならこうです。
const book: Book = {
name: 'オライリー',
[bookNominality]: undefined as never
}
これで一応コンパイルは通るのですが、生成されたJavaScriptコードを見ると、
const book = { name: 'オライリー', [bookNominality]: undefined };
のようになっています。実行時にはbookNominality
はデータとして不要ですし、bookNominality
の変数定義はdeclare
文で書いたのでコンパイル時には消えています。なので、変数未定義エラーが実行時に発生してしまいます。
ReferenceError: bookNominality is not defined
この実行時の問題を解決するためには、bookNominality
をプロパティに与えずに、Book
オブジェクトを作る必要があります。
なので、こうするのがテクニックとしては定番です。
const book: Book = { name: 'オライリー' } as Book
これにも1つ難点があって、Book
型の構造の変化に追従しずらいという問題があります。例えば、Book
型にprice
プロパティが追加されたとしても、as Book
があるところはコンパイルで構造の不一致に気づくことができません。
type Book = {
name: string
price: number
[bookNominality]: never
}
const book: Book = { name: 'オライリー' } as Book // コンパイル通ってしまう
もう少しできることとしては、Book
オブジェクトを作る関数を用意して、それを使うことを強制することです。
// book.ts
declare const bookNominality: unique symbol // これはexportしない
export type Book = {
name: string
price: number
[bookNominality]: never
}
export function newBook(name: string): Book {
return {name: name} as Book
}
// 利用者側のコード.ts
import {Book, newBook} from './book'
const book: Book = newBook('オライリー')
こうすれば、利用者はnewBook
を使わないとBook
オブジェクトを作れないので、Book
型の構造変化に追従しやすくなります。
それでもなお、newBook
の実装ではas Book
があるため、そこの実装だけは構造の変化に注意して直していく必要があるのには変わりがありません。
できれば、as
を使わなくてもいいコードにできればいいのですが……。
そう思いながら試行錯誤した結果、型レベルプログラミングで解決する方法を思いついたので、次回はそのことについて書きたいと思います。
次回 → 公称型をTypeScriptで《より型安全》に実現する方法 - Qiita
最後までお読みくださりありがとうございました。Twitterでは、Qiitaに書かない技術ネタなどもツイートしているので、よかったらフォローお願いします→Twitter@suin