📚

TypeScriptのOmitの実装を見てみよう

2022/02/19に公開

TypeScriptを使い始めて2年以上が経過し、ふと普段からよく使うPickとかPartialなどのユーティリティタイプががどうやって実装されているか知らないなぁと思い、調べて見ました。
本記事ではTypeScriptのユーティリティタイプの1つであるOmitの実装を見ていきたいと思います。

目次

  • ユーティリティタイプとは?
  • Omitとは?
  • Omitの実装
  • Omitの問題点&改善案
  • OmitStrict?
  • まとめ

ユーティリティタイプとは?

ユーティリティタイプとは何なのかというのを軽く紹介したいと思います。
ドキュメントには以下のように書かれています。

TypeScript provides several utility types to facilitate common type transformations. 
These utilities are available globally.

https://www.typescriptlang.org/docs/handbook/utility-types.html より引用

意訳するとこんな感じです。

TypeScriptはよくある型を作成するのに便利な型を用意してる。しかもそれはグローバルで使えるで

例えば以下のようなものがあります。

  • Pick
  • Partial
  • Record
  • Exclude
  • Omit
  • ReturnType

などなど。他にもたくさんあります。
今回これらユーティリティタイプの中から、Omitの実装を見ていきたいと思います。

Omitとは?

まずOmitとは何なのかということですが、日本語では「省く」といった意味で、ある型から一部のプロパティを省いて新しい型を作成することができます。

例えば以下のような User という型があるとします。

type User = {
  id: number;
  name: string;
  nickname: string;
  age: number;
  lang: 'Ja' | 'En';
  level: number;
};

このUserという型からageを除外し、UserWithoutAgeという型を作りたい場合、以下のような感じでOmitを使うことができます。

type UserWithoutAge = Omit<User, 'age'>;

UserWithoutAgeの実態はこのようになっています。

type UserWithoutAge = {
  id: number;
  name: string;
  nickname: string;
  lang: 'Ja' | 'En';
  level: number;
};

ageというプロパティがなくなっていますね。

Omitの実装

それではこのOmit の実装を見ていきたいと思います。
実装はこのようになっています。

type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;

別のユーティリティタイプのPickExcludeが使われていることからOmitはユーティリティタイプの中では後発だということが分かりますね。

除外したいプロパティ以外のユニオンタイプをExcludeを使って作成、それからそのユニオンタイプとPickを使って、除外したいプロパティ以外のプロパティを持つ型を作成しています。

type UserWithoutAge = Omit<User, 'age'>;

先ほど紹介したUserWithoutAgeを作成する流れは以下のようになっています。
前提: T = User, K = age

1: Exclude<keyof T, K>でage以外のUserのプロパティのユニオンタイプを作成

'id' | 'name' | 'nickname' | 'lang' | 'level'

2: 1で作成したユニオンタイプとPickを使って除外したいプロパティ以外のプロパティを持つ型を作成

Pick<User, 'id' | 'name' | 'nickname' | 'lang' | 'level'>

このようにして以下のようなageというプロパティを除外した型を作成しています。

{
  id: number;
  name: string;
  nickname: string;
  lang: 'Ja' | 'En';
  level: number;
};

Omitの問題点&改善案

このOmitなんですが、1つ問題があります。
それはOmitの第二型引数であるKの制約が緩いことです。もう一度Omitの実装を見てみましょう。

type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;

型引数Kに対してextendsを使い型制約を加えているのですが、keyof anyを使っており、制約がゆるくなっています。

keyof anyは下記画像のようになり、オブジェクトや配列以外であればだいたい受け入れてくれます。

これによってどういう問題が生じるかというと、プロパティにない文字列を2つ目の引数に指定してもエラーにならないんですね。
例えば、Userのプロパティにないaaaaみたいな適当な文字列を指定してもエラーになりません。

これだとタイプミスに気づかない恐れがありますし、何よりエディターの補完が効かないのが辛いです。

そこで改善案を考えました。それがこちらです。

type MyOmit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;

keyof anyだった部分をkeyof Tに変えています。こうすることで2つ目の型引数には、1つ目の型引数のプロパティの文字列しか指定できなくなります。

Userのプロパティにないaaaaを指定するとエラーが出るようになっています。

OmitStrict?

Omitの問題点を解消する改善案を考えてみましたが、keyof anykeyof Tを変えるだけというとてもシンプルな修正だったので、他にも同じことを考えている人がいるのではないか、TypeScript側も気付いているのではないかと考えました。

そしてgithubのissueを見てみたところ、やはりありました。それがこちらです。
https://github.com/microsoft/TypeScript/issues/30825

このissueで議論されていて初めて知ったのですが、Omitというユーティリティタイプを追加して欲しいという依頼があった時はkeyof anyではなく、型制約が今より厳しかったそうです。それが何故かTypeScript開発チームが実際にOmitを追加した時(PR)にはkeyof anyになっていた。それだと第二型引数の制約が緩いのでもう少し厳しくしてくれというのがこのissueでの主張ですね。

この意見には多くの人が賛同していました。しかし、それに対するTypeScriptの中の人の回答がこちらです。

It seems like the constrained Omit type is going to make at least half of users unhappy based on declarations within DefinitelyTyped. We've decided to go with the more permissive built-in which your own constraints can build on.

(意訳: そんなことすると半分以上のユーザーがアンハッピーになる。自分で厳しめの型作れるようにライブラリのOmitは緩めにしとくわ)

これに対して色んな人が反対意見を投稿していました。例えば以下のような感じです。

  • もう半分のユーザーのことも考えてくれ!!
  • どうしてPickは厳しいのにOmitはゆるいんだ!!
  • Omitは残したままで良いからOmitStrictを作ってくれ!!
  • 型の制約が厳しい方が良い!!
  • 上位のライブラリtype-festとtypicalどちらもプロパティだけを指定できるようになっている!!
  • LooseOmitとStrictOmit両方作ってどっちを使うか選ばせれば良いのでは?

これだけ否定的な意見があるにも関わらず、TypeScript側は屈せず最終的にこのように答えております。
https://github.com/microsoft/TypeScript/issues/30825#issuecomment-523668235
(長いのでリンク貼りました)

意訳すると、Omitの制約厳しい版は作りません。厳しさの定義は人それぞれ違うし、すでにOmitStrictという名前で独自に作っているユーザーもいる。ライブラリ側でOmitStrictを作ると名前が衝突して良くない。といった感じのことを言っていました。

これに対して30件ほどバッドマークはついていましたが、TypeScript側としてはOmitは今のままで、制約が厳しいバージョンは作らないということです。

というわけで僕が指摘した問題点にはTypeScript側も気付いていて、あえてこうしているようです。

まとめ

今回はTypeScriptのユーティリティタイプの1つであるOmitについて見ていきました。その中で問題点を発見し、その問題点は深い議論が実施されたうえであえて残されているということが分かりました。

改善するのは簡単だけど、既存ユーザーのことを考えると変更できないというライブラリ開発者の苦労を少し感じられたような気がします。

そして今回Omit以外にもユーティリティタイプの実装を見たことで、TypeScriptについてより詳しくなれたかなと思います。他のいくつかのユーティリティタイプの実装も共有しますので見てみてください。

Pick
type Pick<T, K extends keyof T> = {
    [P in K]: T[P];
};
Record
type Record<K extends keyof any, T> = {
    [P in K]: T;
};
Exclude
type Exclude<T, U> = T extends U ? never : T;
ReturnType
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;

Discussion