Help us understand the problem. What is going on with this article?

公称型をTypeScriptで実現するための基礎

先日、異なる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

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした