105
57

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-17

先日、公称型をTypeScriptで実現するための基礎という投稿をしました。そこでは、「部分的構造型」というタイプに分類される型システムを持つTypeScriptで、JavaやPHPなどが採用している「公称型」はどうやったら実現できるのかについてお話しました。

ざっくりおさらいすると、3つのテクニックを組み合わせるというものでした。

  • テクニック1: 構造を微妙にずらして型同士の互換性を崩そう
  • テクニック2: 構造ずらしにはunique symbolを使おう
  • テクニック3: 変数の生成するための関数を作って、変更に強くしよう

これらのテクニックを実践したサンプルコードは次のようになります。

book.ts
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
}
person.ts
export type Person = {
  name: string
  [personNominality]: never // テクニック1
}
declare const personNominality: unique symbol
export function newPerson(name: string): Person {
  return { name } as Person
}
利用者側コード.ts
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を除いたオブジェクトなので、namepriceをちゃんと持っているオブジェクトであればコンパイルが通ります。

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が無いことがコンパイルエラーで指摘されるようになります。
}

リファクタリング後の完全なコードは次のようになります。

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

105
57
3

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
105
57

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?