Strutual TypingとNominal Typing
TypeScriptは構造的型付け(Structual Typing)を行う言語です。これは構造的に同じ形の型なら互換性があるとみなすということです。例えば、次の例を考えます。
type Book = {
name: string
}
type Person = {
name: string
}
let book = {name: 'Effective TypeScript'}
let person:Person = book // BookとPersonは同じ構造なのでコンパイルが通る
このコードはコンパイルが通ります。BookとPersonはどちらも「string型のnameをメンバーにもつ」という同じ構造をしているからです。
一方で、JavaやC++などでは、同様なコードを書いてもコンパイルは通りません。これらの言語は構造が同じでも継承関係のない型同士は互換性がないからです。このような方式を名前的型付け(Nominal Typing、公称型)といいます。
Structual TypingとNominal Typingは、どちらが良いというわけではなく、それぞれにメリット、デメリットがあります。より厳密な型チェックが行えるという点では、Nominal Typingに利点があります。Structual Typingは型チェックはやや緩くなりますが、Duck Typingが型セーフに行えるというメリットがあります。またTypeScriptはJavaScriptにあとから静的型定義を導入した言語なので、Nominal Typingにしてしまうと、JavaScriptとの相互運用などに支障がでてしまいます。
TypeScriptがStructual Typingとは言っても、状況によっては、Nominal Typingのような名前による型チェックを行えたほうが便利な場合もあるでしょう。もし先程の例が、DDDでいうところのValue Objectのようなものであった場合、BookとPersonのように同じ構造であっても意味が違うものには互換性を持たせたくはありません。
この記事では、Type ScriptでNominal Typingを実現する方法を紹介します。
結論
まず最終形態を示してから、解説をします。
再利用可能なユーティリティとして以下のような関数を用意します。
export default function val<T>(value: Omit<T, '_brand_'>) {
return value as T
}
これを使って最初の例を以下のように書き換えます。
import val from './val'
type Book = {
name: string
} & { readonly _brand_: unique symbol}
type Person = {
name: string
} & { readonly _brand_: unique symbol}
let book = val<Book>({name: 'Effective TypeScript'})
let person:Person = book // BookとPersonは(_brand_以外は)構造が同じだがコンパイルエラー
これでBookとPersonは非互換になります。またBookにメンバーが追加になったときには、インスタンス生成時に追加のメンバーも初期化しないとコンパイルエラーになります。
import val from './val'
type Book = {
name: string
price: number
} & { readonly _brand_: unique symbol}
let book = val<Book>({name: 'Effective TypeScript'}) // コンパイルエラー
型定義
まず最終形態のうち型定義の部分からみます。
type Book = {
name: string
} & { readonly _brand_: unique symbol}
type Person = {
name: string
} & { readonly _brand_: unique symbol}
この時点で、BookとPersonは非互換になります。それぞれ固有の型を持つ_brand_をメンバーに持つからです。
_brand_を、nameと同列のメンバーとして並べてもよいのですが、交差型として分離しておくと、stringなど既存の型に対しても使えるので便利です。
type BookName = string & { readonly _brand_: unique symbol}
type PersonName = string & { readonly _brand_: unique symbol}
let bookName:BookName = ...
let personName: PersonName = bookName // BookNameとPersonNameは非互換なのでコンパイルエラー
型アサーションによるインスタンス化
次はインスタンスの作成です。残念ながら元から必要だったメンバーだけ初期化してもコンパイルエラーになります。追加した_brand_をメンバーに持たないからです。
type Book = {
name: string
} & { readonly _brand_: unique symbol}
let book:Book = {name: 'Effective TypeScript'} // _brand_がないのでコンパイルエラー
自明な解決方法としては、型アサーションによる解決があります。
let book:Book = {name: 'Effective TypeScript'} as Book
この方法だとBookにメンバーが追加になったとしてもコンパイルが通ってしまうという欠点があります。
type Book = {
name: string
price: number
} & { readonly _brand_: unique symbol}
let book:Book = {name: 'Effective TypeScript'} as Book // priceがないのにコンパイルが通る
ユーティリティー関数による型チェック
そこでインスタンス生成時の型チェックを行うために以下のような関数を用意します。
export default function val<T>(value: Omit<T, '_brand_'>) {
return value as T
}
Omit<T, '_brand_'>
は与えられた型Tから、指定したメンバー _brand_
を取り除いた型を返します。
つまり関数val<T>(value)
は、valueが_brand_
以外のメンバーをすべて持っていることをチェックします。
これで以下のようなインスタンス化はエラーになります。
type Book = {
name: string
price: number
} & { readonly _brand_: unique symbol}
let book:Book = val<Book>({name: 'Effective TypeScript'}) // priceがないのでコンパイルエラー
これで、BookとPersonは同じ構造を持ちつつ、インスタンス生成時の型チェックもできるようになります。
まとめ
_brand_
やval<Book>
のような余計なものが多少は入ってしまいましたが、一応、TypeScriptでもNominal Typingのようなことができるようになりました。
※著者はTypeScript初心者なので間違っていたらすみません。
参考
この記事を書くのに以下を参考にしました。