先日、公称型をTypeScriptで実現するための基礎という投稿をしました。そこでは、「部分的構造型」というタイプに分類される型システムを持つTypeScriptで、JavaやPHPなどが採用している「公称型」はどうやったら実現できるのかについてお話しました。
ざっくりおさらいすると、3つのテクニックを組み合わせるというものでした。
- テクニック1: 構造を微妙にずらして型同士の互換性を崩そう
- テクニック2: 構造ずらしには
unique symbol
を使おう - テクニック3: 変数の生成するための関数を作って、変更に強くしよう
これらのテクニックを実践したサンプルコードは次のようになります。
export type Book = {
name: string
[bookNominality]: never // テクニック1
}
declare const bookNominality: unique symbol // テクニック2
// テクニック3
export function newBook(name: string): Book {
return { name } as Book
}
export type Person = {
name: string
[personNominality]: never // テクニック1
}
declare const personNominality: unique symbol
export function newPerson(name: string): Person {
return { name } as Person
}
import { Book, newbook } from './book.ts'
import { Person, newPerson } from './person.ts'
let book: Book = newBook('オライリー')
let person: Person = newPerson('Bob')
book = person // 期待通りコンパイルエラーになる
このテクニックは、概ね実用レベルですが、弱点についてもお話しました。それは、as
の部分だけが型安全性が低下するというところです。例えば、Book
型にprice
プロパティが追加された場合、newBook
関数も直さないといけません。
このとき、直す必要がある箇所はコンパイラが教えてくれると、修正忘れがなくなりいいのですが、as
が書かれているせいでコンパイラは何も教えてくれなくなります。
export type Book = {
name: string
price: number // 追加
[bookNominality]: never //
}
export function newBook(name: string): Book {
return { name } as Book // priceが足りなくてもコンパイルエラーにはならない……
}
newBook
関数はBook
型の定義に近い場所に書いてあるため、修正漏れのリスクはだいぶ低いとは思いますが、それでもリスクがゼロになったわけではありません。
公称型をTypeScriptを型安全に実現する方法
本稿では、{ name } as Book
をより型安全にする、つまり、コンパイル時に修正漏れに気づきやすくする方法を考えていきたいと思います。
それでは、問題箇所の
export function newBook(name: string): Book {
return { name } as Book
}
をリファクタリングしていきましょう。
まず、as Book
の目的を考えます。これは、bookNominality
の値を代入せずに、Book
型のオブジェクトを作りたいがために書いています。
もっというと、bookNominality
以外のすべてのプロパティがそろったオブジェクトに限り、Book
型として扱いたいというのがコードの意図になります。
この意図をより正確に表すために、bookNominality
以外すべてのプロパティを持ったオブジェクトの型を定義します。特定のプロパティを除いた型を定義するには、Omit
を使うと便利です。
type BookLike = Omit<Book, typeof bookNominality>
BookLike
型はbookNominality
を除いたオブジェクトなので、name
とprice
をちゃんと持っているオブジェクトであればコンパイルが通ります。
export interface Book {
name: string
price: number
[bookNominality]: never //
}
type BookLike = Omit<Book, typeof bookNominality>
// ↓コンパイル通ります
const book: BookLike = {
name: 'オライリー',
price: 1000
}
次に、BookLike
を受け取り、Book
を返す関数を作ります。
function newBookSafe(bookLike: BookLike): Book {
return bookLike as Book
}
この関数でもas
を用いているので、ここは型安全性が弱まっていますが、BookLike
型がすでにbookNominality
以外のプロパティを持ったBook
型であることを保証しているので、newBook
関数の{ name } as Book
よりは安心して扱うことができます。また、BookLike
は自動的に、Book
の構造変化に追従していくので、今後newBookSafe
の実装を修正する必要が出てくるこのはありません。
最後に、newBookSafe
関数をnewBook
関数から呼び出すように修正すれば、リファクタリングは完了です。
export function newBook(name: string): Book {
return newBookSafe({ name }) // ここでpriceが無いことがコンパイルエラーで指摘されるようになります。
}
リファクタリング後の完全なコードは次のようになります。
export interface Book {
name: string
price: number
[bookNominality]: never //
}
declare const bookNominality: unique symbol
type BookLike = Omit<Book, typeof bookNominality>
function newBookSafe(bookLike: BookLike): Book {
return bookLike as Book
}
export function newBook(name: string): Book {
return newBookSafe({ name }) // 「priceが足りないよ」とコンパイラが教えてくれます。
}
このコードは実際にTypeScript Playgroundで動かしてみることができます。
まとめ
本稿では、公称型をTypeScriptで実現するための基礎で取り上げた手法の弱点だった型安全性の弱まりを軽減する方法を説明してきました。
そのアプローチとしては、
- 公称型のために定義されたプロパティを除いた、型を定義する
- その型を
as
で型変換する
という方法でした。
このアプローチはたしかに型の安全性は高まったと言えますが、デメリットとしては、公称型を実現するための型定義や関数が増えてしまうという点です。先のBook
の例でいうと、
type BookLike = Omit<Book, typeof bookNominality>
function newBookSafe(bookLike: BookLike): Book {
return bookLike as Book
}
の部分です。今回はBook
型しか扱わなかったので、4行追加するだけのリファクタリングで収まりましたが、公称型にしたい型の数だけこのリファクタリングをやるとなると、同じようなコードをいたるところで書かないといけなくなり、かなり面倒になることが予想されます。
次回は、更にリファクタリングを進めて、型安全性を今回のアプローチと同等に保ちつつ、コードの再利用性を高める方法を紹介していこうと思います。
最後までお読みくださりありがとうございました。Twitterでは、Qiitaに書かない技術ネタなどもツイートしているので、よかったらフォローお願いします→Twitter@suin