TypeScript

TypeScriptでNominal Typingを実現する4つの方法

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を実現するパターンをいくつか紹介する。

クラス構造の互換性を崩す

Structural Typingは構造の互換性を重視する。従って、構造さえ異なっていれば互換性のない型を作ることができる。

:one: 変数名が異なる属性をクラスに持たせる

その一つのほうほうが、互換性を持たせたくないクラスに異なる名前の属性を持たせることだ。

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() // コンパイルエラー
         ~~~~~

:two: 型が異なる属性をクラスに持たせる

構造を崩すために、名前は同じでも型が異なる属性を持たせる別の方法もある。

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() // コンパイルエラー
       ~~~~

:three: ジェネリクスを使う

上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() // コンパイルエラー

:four: 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型には使えないところだ。enumnumber型と互換しているためだ。次のコードはコンパイルエラーにならない:

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

関連記事