TypeScriptでObject.assign()に正しい型をつける
2019/06/28
TL;DR
Conditional TypesやTuple関連の機能強化により、現在のTypeScript(v3.5.2)では
Object.assign()
に正しい型をつけられることを知ったので、紹介します。結論から書くと次のとおりです。type Assigned<T, U extends any[]> = {
0: T;
1: ((...t: U) => any) extends ((head: infer Head, ...tail: infer Tail) => any)
? Assigned<Omit<T, keyof Head> & Head, Tail>
: never;
}[U['length'] extends 0 ? 0 : 1];
type Assign = <T, U extends any[]>(target: T, ...source: U) => Assigned<T, U>;
// Usage
declare const myObjectAssign: Assign;
myObjectAssign(
{ a: new Date() },
{ a: '' },
{ b: /b/ },
{ c: 1 },
{ d: false }
).a.foo; // Error: Property 'foo' does not exist on type 'string'.
// Original Object.assign()
Object.assign(
{ a: new Date() },
{ a: '' },
{ b: /b/ },
{ c: 1 },
{ d: false }
).a.foo; // no error
前置き
フロントエンドエンジニアの今村です。先日TypeScript Meetupがあったりして、私自身は参加していないのですがtwitterでタイムラインを眺めていたら久しぶりにTypeScriptにdeep diveしてみたい気持ちが高まってきたので、今回はそんなネタを扱います。
Variadic Kinds
TypeScriptで可変長引数を扱う関数の型を宣言するのが難しい問題は古くからよく知られています。例として、
Object.assign()
にTypeScriptがどのような型をつけているか見てみましょう。// https://github.com/microsoft/TypeScript/blob/v3.5.2/lib/lib.es2015.core.d.ts より
interface ObjectConstructor {
assign<T, U>(target: T, source: U): T & U;
assign<T, U, V>(target: T, source1: U, source2: V): T & U & V;
assign<T, U, V, W>(target: T, source1: U, source2: V, source3: W): T & U & V & W;
assign(target: object, ...sources: any[]): any;
// ...
}
泥臭いです。引数が2個、3個、4個の場合を個別に記述する必要があり、それもいつまでも続けるわけにもいかないので、5個以上の場合は
any
を返してしまっています。この問題を解決するために、Variadic Kindsという機能が提案されており、もう何年もTypeScriptのRoadmapに居座っているのですが、なかなか導入されません。私自身、この機能が導入されるのをわりと待ち焦がれていたのですが、Conditional Typesが導入されたりTupleの型を厳密に扱えるようになったりしたことで、Variadic Kindsがなくてもなんとかなりそうということを知りました。
それで
Object.assign()
に正しい型をつけることを思い立ち、できたのが冒頭のコードです。コードの説明
type Assigned<T, U extends any[]> = {
0: T;
1: ((...t: U) => any) extends ((head: infer Head, ...tail: infer Tail) => any)
? Assigned<Omit<T, keyof Head> & Head, Tail>
: never;
}[U['length'] extends 0 ? 0 : 1];
この
type
は、Object.assign()
の第一引数(T
)と第二引数以降(U
)を分離するためのものです。U
の配列の長さが0
ならT
をそのまま返し、それ以外ならU
の先頭の要素をT
(からOmit
でU
の先頭の要素に含まれるプロパティを除外したもの)に合成したものとU
の2番目以降の要素を使ってAssigned
を再帰的に利用します。((…t: U) => any) extends ((head: infer Head, …tail: infer Tail) => any)
でU
のHead
とTail
を分離しているのが非常にトリッキーなところです。自分ではこんなやり方を思いつく自信はありませんが、“How to master advanced TypeScript patterns”という記事で紹介されていてなるほどなーと思いました。このあたり、まるで関数型言語のパターンマッチングのようで、とても美しいと思いませんか?type Assigned<T, U extends any[]> = U['length'] extends 0
? T
: ((...t: U) => any) extends ((head: infer Head, ...tail: infer Tail) => any)
? Assigned<Omit<T, keyof Head> & Head, Tail>
: never;
のような書き方はできないというのがあります。このように書くと、
Type alias 'Assigned' circularly references itself.
といって怒られます。このissueなんかを読むと、TypeScriptでは通常
type
をeagerlyに評価しようとするため、循環参照はエラーになってしまうようです。ただし、最初のように一度
{ 0: ...; 1: ...; }
のような形に包んでそこからConditional Typesによりindexを指定して中身を取り出す書き方をしておくと、実際にT
やU
などの型パラメータが与えられて初めて型を評価するようになるため、循環参照してもエラーにならないそうです(このあたり、正確なルールは理解できていません)。Assigned
さえできてしまえば、あとはそれほど難しくないでしょう。type Assign = <T, U extends any[]>(target: T, ...source: U) => Assigned<T, U>;
ここでは、
Assigned
を使ってObject.assign()
相当の関数の型を定義しています。実際にこれを使うと、引数の数が多くても(5個以上でも)戻り値が正確に推論されていることが分かります。参考
最近のTypeScriptの型システムは本当に強力になっており、今回紹介したものはその一端を示すにすぎません。TypeScriptの強力さをじっくり味わいたい方は、次のようなパッケージの実装を眺めてみるとよいかと思います。
- typescript-tuple: Tuple関連の型ユーティリティライブラリ
- ts-toolbelt: Tupleに限らず幅広い種類の型に関連した機能を提供する型ユーティリティライブラリ
終わりに
カブクのフロントエンドではTypeScriptをバリバリ使っています。TypeScript好きなエンジニアの方はぜひ採用にご応募ください!
その他の記事
Other Articles
関連職種
Recruit