お正月です。一年の計は元旦にありと言いますし、TypeScriptと仲良くなろうと思って Type Challenges を始めました。
問題
この中に Omit<T>
を実装せよ、という難易度mediumの問題があります。
type MyOmit<T, K> = any; // いい感じに実装する
interface Todo {
title: string
description: string
completed: boolean
}
// {title: string} 型になるようにしたい
type TodoPreview = MyOmit<Todo, 'description' | 'title'>
解答
基本
これはもちろんmapped typesを知っていれば一瞬で解ける問題で、ビルトインの Exlude<T, K>
を使って
type MyOmit<T, K> = {
[P in Exclude<keyof T, K>]: T[P]
}
で終わりです。難易度mediumどころではありません。
ところが、これをビルトインの型や自前の型などを使わず1行で実現しようとするとちょっと難儀します。単に Exclude<T, K>
の定義をそのまま展開しようとしてもうまくいきません。
type MyOmit_notwork<T, K> = { // ダメ
[P in (keyof T extends K ? never: keyof T)]: T[P]
}
丸カッコ中のconditional typesは never
か keyof T
(引数そのもの)かのいずれかを返すので直感的にも明らかですね。
type hoge = MyOmit_notwork<Todo, "description"> // Todo型そのものになってしまう
提出された解答を眺めていたところ、これを解決しているものがいくつかあり、こんな文法知らんかったわ! という発見もあったので書きます。怪しい点などありましたら突っ込みをお願いします。なおTypeScript v4.1.3で確認しています。
その1: asを使う
type MyOmit<T, K extends keyof T> = {[P in keyof T as P extends K ? never: P] :T[P]}
これが一番シンプルでした。キモとなるのはTypeScript v4.1から入ったmapped type 'as' clausesです。
-
as
句でP
を別の型にキャストできる - ということはconditional typesを書くこともできる
というわけでこのような記法が許されます。Template literal typesばかりが注目されていましたがこんな使い方もできるんですね。
その2: 3つめの変数を使う
type MyOmit<T, K, Keys extends keyof T = keyof T> = {
[key in (Keys extends K ? never : Keys)]: T[key]
}
第3の引数を型演算の変数に使うというアイディアです。発想力の勝利です。
その3: 型パズルを頑張る
これが一番型パズルっぽい解答でした。まず最初に次のような型を考えます。
// Echo<"a"|"b", "b"|"c"> のように使い、Kの値にかかわらずTをそのまま返す
type Echo<T, K> = T extends infer U ? U : never
これは何が嬉しいかというと、後でconditional typesを繋げていくとき T
に U
という別名が付けられる点です。
なお、これはビルトインの Extract<T, K>
と似ていますが結果はまったく異なることに注意しましょう。 Extract<T, K>
は T
と K
の共通部分を取り出すものです。
// 以下3つはすべて "a"|"b" 型
type T1 = Echo<"a"|"b", "c">
type T2 = Echo<"a"|"b", "b"|"c">
type T3 = Echo<"a"|"b", never>
// Extract<T, K>の定義: type Extract<T, K> = T extends K ? T : never
type T4 = Extract<"a"|"b", "b"|"c"> // "b" 型
type T5 = Extract<"a"|"b", "c"> // never 型
次に本命の Exclude<T, K>
について考えます。先ほどの Echo<T, K>
は U
を通じて T
そのものを返しましたが、これを Exclude<T, K>
を返すように書き換え、さらに定義を展開してみます。
type Echo<T, K> = T extends infer U ? U : never
type MyExclude1<T, K> = T extends infer U ? Exclude<U, K> : never
type MyExclude2<T, K> = T extends infer U ? U extends K ? never : U : never
ごちゃごちゃしてきましたが、これは「『 T
そのものを返す Echo<T, K>
』をいじって Exclude<T, K>
を返すようにした」ものなので、 Exclude<T, K>
とまったく同じ結果を返すことが分かります。
type T6 = MyExclude2<"a"|"b", "b"|"c"> // "a"
type T7 = MyExclude2<"a"|"b", "c"> // "a"|"b"
嬉しいことに、最終結果には T
ではなく U
が使われています。つまりこいつを Exclude<T, K>
の代わりに突っ込んでやれば……
type MyOmit<T, K> = {
[P in Exclude<keyof T, K>]: T[P]
}
// ↓
type MyOmit<T, K> = {
[P in MyExclude2<keyof T, K>]: T[P] // Type 'P' cannot be used to index type 'T'.
}
エラーになってしまいました。TypeScriptは T
と MyExclude2<keyof T, K>
の結果との関係を理解できないようです。
そこでもうひとひねりして、最後の U
の代わりに Extract<U, T>
を入れてやります。もともと U
は T
の部分集合なので、 Extract<U, T>
は U
そのものを返します。つまり Extract<U, T>
は何も仕事をしないのですが、TypeScriptはこれで U
と T
との関係を理解できるようになります。
type MyExclude2<T, K> = T extends infer U ? U extends K ? never : U : never // これだとダメなので
type MyExclude3<T, K> = T extends infer U ? U extends K ? never : Extract<U, T> : never // こうしてやる
type MyOmit<T, K> = {
[P in MyExclude3<keyof T, K>]: T[P] // 通った!!
}
最後に MyExclude3<T, K>
と Extract<U, T>
の定義を展開してやれば終わりです。
type MyExclude4<T, K> = T extends infer U ? U extends K ? never : U extends T ? U : never : never // 展開
type MyOmit<T, K> = {
[P in (keyof T extends infer U ? U extends K ? never : U extends keyof T ? U : never : never)]: T[P]
}
まとめ
2021年も頑張ります💪