123
55

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-02-14

先日、異なる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に書かない技術ネタなどもツイートしているので、よかったらフォローお願いします:relieved:Twitter@suin

123
55
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
123
55

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?