どんなシーンを想定しているか
includes[]
パラメータの有無によって動的にレスポンスが変わる Web API があったとします。
# リクエスト
- GET https://example.com/profile/1
+ GET https://example.com/profile/1?includes[]=age&include[]=image
# レスポンス
{
"id": 1,
"name": "Taro",
+ "age": 25,
+ "image": "https://example.com/images/profile/1"
}
このレスポンスの型を素直に書くと、以下のように Nullable (Null許容型) にするのではないでしょうか?
type Profile = {
id: number;
name: string;
age?: number;
image?: string;
}
問題点
しかし、これには問題があります。
例えば、age も image も含めた includes
パラメータを受け取り API を実行した場合、値が入っているにも関わらず、undefined
の可能性があると判定されるのです。
const params = {
id: 1,
includes: ['age', 'image']
}
const profile = await profileApi.fetch<Profile>(params); // fetch のジェネリクス型に戻り値の型を指定
console.log(profile.age > 30); // NG
// 'profile.age' は 'undefined' の可能性があります。ts(18048)
// ↓ これならOK
console.log(profile.age && profile.age > 30);
解決策
コメント欄で @sugoroku_y さんから教えていただいたPickなどのUtility Typesを使った簡潔な手法がありますので、そちらをご覧ください
https://qiita.com/mkin/items/b0c0bfecac9e21f5fded#comment-080284e4a8a610916065
以降の記事は簡潔でないケースとして残しておきます。
これを解決するためにやることは以下の2点です。
簡潔でないケース
1. ベースになる型と動的になる型を分離する
2. Conditional Type を使って交差型を作る
// 1. ベースになる型と動的になる型を分離する
type ProfileBase = {
id: number;
name: string;
}
type AgeParam = { age: number } // Not Nullable
type ImageParam = { image: string } // Not Nullable
// 2. Conditional Type を使って交差型を作る
type Profile<IncludesParam = ''> = ProfileBase
& ('age' extends IncludesParam ? AgeParam : ProfileBase)
& ('image' extends IncludesParam ? ImageParam : ProfileBase)
&
を使って型を結合する(交差型を作る)と、必要な型のみ生成することができます。
配列にマッチしなければ、ベースの型を結合する(=変化なし)ようにしています。
試しに、Profile 型が正しく動作するか検証してみます。
// OK
const profile: Profile = { id: 1, name: 'Taro' }
補足として、ジェネリクス型を type Profile<IncludesParam = ''>
のようにデフォルト値を設定しているため、ジェネリクス型を指定する必要はありません。
// age のみ
const includes = ['age'] as const;
type IncludesParam = (typeof includes)[number];
// OK
const profile2A: Profile<IncludesParam> = { id: 1, name: 'Taro', age: 25 }
// NG
const profile2B: Profile<IncludesParam> = { id: 1, name: 'Taro' }
// Type '{ id: number; name: string; }' is not assignable to type 'Profile<"age">'.
// Property 'age' is missing in type '{ id: number; name: string; }' but required in type 'AgeParam'.(2322)
// age と image
const includes = ['age', 'image'] as const;
type IncludesParam = (typeof includes)[number];
// OK
const profile3c: Profile<IncludesParam> = { id: 1, name: 'Taro', age: 25, image: 'https://example.com/images/profile/1' }
// NG
const profile3a: Profile<IncludesParam> = { id: 1, name: 'Taro' }
const profile3b: Profile<IncludesParam> = { id: 1, name: 'Taro', age: 25 }
うまく動いているようです。
先程の Web API 呼び出しもこのようになるでしょう。
+ const includes = ['age', 'image'] as const;
const params = {
id: 1,
- includes: ['age', 'image']
+ includes
}
- const profile = await profileApi.fetch<Profile>(params);
+ type IncludesParam = (typeof includes)[number];
+ const profile = await profileApi.fetch<Profile<IncludesParam>>(params);
console.log(profile.age > 30); // OK
おわりに
TypeScript の型をきれいに書くことで、不要な undefined
の判定処理を無くせることが分かりました。一方で、コードの記述量が多くなり、初めて見るときに身構えてしまうかもしれません。
そんなときは、TypeScript はあくまで JavaScript をサポートするためのもので、処理に影響を与えないことを意識するとよいかと思います。
つまり、type 〜
や 〜<...>
の型部分を除外してみると、意外とコード量は多くなかったりします。
もっと良い方法がありましたら教えていただけると幸いです