JavaやPHPの型システムはNominal Typing(公称型)と呼ばれ、クラス名の一致で型の互換性を識別する。一方のTypeScriptはStructural Typing(構造型)と呼ばれ、構造さえ同じなら互換性ありと識別する。
例えば、次のUser
クラスとProduct
クラスは共通してname
属性を持っており、同じ構造になっている。つまり、相互に互換性がある。従ってTypeScriptではコンパイルエラーにならない。
class User {
name: string
}
class Product {
name: string
}
let user: User = new Product() // コンパイルエラーにならない
let product: Product = new User() // コンパイルエラーにならない
TypeScriptでNominal Typingを実現する方法
TypeScriptでもNominal Typingをサポートして欲しいというリクエストがGitHubのissueに出ているが、執筆時最新のTypeScript2.7.1でもサポートはされていない。
将来的にこのissueが解決されるかもしれないが、本稿では今日現在で実施可能なTypeScriptでNominal Typingを実現するパターンをいくつか紹介する。Nominal Typing実現手法の基本的なことがらについては「Nominal TypingをTypeScriptで実現するための基礎」を参照していただきたい。
クラス構造の互換性を崩す
Structural Typingは構造の互換性を重視する。従って、構造さえ異なっていれば互換性のない型を作ることができる。
変数名が異なる属性をクラスに持たせる
その一つのほうほうが、互換性を持たせたくないクラスに異なる名前の属性を持たせることだ。
class User {
_user: any
name: string
}
class Product {
_product: any
name: string
}
let user: User = new Product() // コンパイルエラー
このコードでは_user
属性と_product
属性がそれぞれのクラスで異なるためコンパイルエラーになる:
src/test.ts:10:5 - error TS2322: Type 'Product' is not assignable to type 'User'.
Property '_user' is missing in type 'Product'.
10 let user: User = new Product() // コンパイルエラー
~~~~
この手法はTypeScript本家でも用いられているパターンでもある。
変数名だけで型のユニークさを保証するのに限界がある。_user
のような短い変数名では他のクラスと重複することも考えられる。そういった心配はSymbol
を使うことで退けられる。
const UserType = Symbol()
class User {
[UserType] : any
name: string
}
const ProductType = Symbol()
class Product {
[ProductType] : any
name: string
}
const user1: User = new User()
const user2: User = new Product() // コンパイルエラー
このコードは次のとおり、コンパイルエラーにすることができる:
src/test.ts:13:7 - error TS2322: Type 'Product' is not assignable to type 'User'.
Property '[UserType]' is missing in type 'Product'.
13 const user2: User = new Product() // コンパイルエラー
~~~~~
型が異なる属性をクラスに持たせる
構造を崩すために、名前は同じでも型が異なる属性を持たせる別の方法もある。
class User {
_class: "user"
name: string
}
class Product {
_class: "product"
name: string
}
let user: User = new Product() // コンパイルエラー
User
クラスもProduct
クラスも共通して_class
属性を持っているがその型は異なる。前者は文字列の"user"
しか受け付けず、後者は"product"
しか許さない。つまりコンパイルエラーにすることができる:
src/test.ts:10:5 - error TS2322: Type 'Product' is not assignable to type 'User'.
Types of property '_class' are incompatible.
Type '"product"' is not assignable to type '"user"'.
10 let user: User = new Product() // コンパイルエラー
~~~~
ジェネリクスを使う
上2つのパターンでは、クラスの機能として無意味な属性を各クラス属性に書いていく鬱陶しさがある。加えて、初めてそのパターンを目にする他のデベロッパにとっては「なぜ使われない_class
属性があるのか」と疑問がわいたり、「消しても差し支えないだろう」といった誤解が生じるだろう。ジェネリクスを使うパターンはいくらかは説明的で、疑問や誤解を避けることもできる。
abstract class Nominal<T extends string> {
_class: T
}
class User extends Nominal<"user"> {
name: string
}
class Product extends Nominal<"product"> {
name: string
}
let user: User = new Product() // コンパイルエラー
Enumと交差型
属性に差を付ける方法とは別のアプローチにEnumと(Intersection Types)を使う方法がある。次のように実装する。
- 区別したい型ごとに
enum
を定義する。 -
enum
と本体の型を&
(intersection)で結ぶ。
enum UserIdType {}
type UserId = UserIdType & string
enum ProductIdType {}
type ProductId = ProductIdType & string
const userId1: UserId = "1" as UserId
const userId2: UserId = "1" // コンパイルエラー
const userId3: UserId = "1" as ProductId // コンパイルエラー
このコードは次のようなコンパイルエラーを見ることになる:
src/test.ts:7:7 - error TS2322: Type '"1"' is not assignable to type 'UserId'.
Type '"1"' is not assignable to type 'UserIdType'.
7 const userId2: UserId = "1"
~~~~~~~
src/test.ts:8:7 - error TS2322: Type 'ProductId' is not assignable to type 'UserId'.
Type 'ProductId' is not assignable to type 'UserIdType'.
8 const userId3: UserId = "1" as ProductId
~~~~~~~
この方法の注意点としてはnumber
型には使えないところだ。enum
がnumber
型と互換しているためだ。次のコードはコンパイルエラーにならない:
enum UserIdType {}
type UserId = UserIdType & number
enum ProductIdType {}
type ProductId = ProductIdType & number
const userId1: UserId = 1 as UserId
const userId2: UserId = 1
const userId3: UserId = 1 as ProductId