[TypeScript] 型地獄で戦うための定石メモ
※ こちらでも同じ記事を書いています
汎用的な型変換の必要性
TypeScriptで型を扱っていると、与えられたデータ構造に対して組み替えをかけ、新たな構造を作成し値を返す必要が生じます。これが不変の構造ならばもちろん都度、手動で定義しても問題はありません。しかしAPI作成時、ツールで自動生成されるような型に対応するのは大変な労力です。こういう場合に汎用的な型構造を作ることが出来れば、自動生成された型に対して、自動で対応可能です。今回はそういった時のための型定義の定石を紹介します
GenericsとType
typeの一般的な使い方は知っている物として話を進めます。TypeでGenericsを使い、extendsで型の変換を行う場合は以下のような書き方になります
type タイプ名<型変数 extends 制約,...> = 型変数 extends 条件 ? 真の型 : 偽の型
=
より前のextends
と後のextends
で意味の異なることに注意が必要です。前者はタイプ利用時に間違った方が入った時点で文法エラーとなります。後者は条件によって返す方を分岐させる物です。
Genericsに登場するinfer
type タイプ名<型変数 extends 制約,...> = 型変数 extends 条件(infer 取り出し変数) ? 真の型(取り出した変数が使用可能) : 偽の型
infer
は後で例を挙げて説明しますが条件の一部として組み込むことにより、その部分だけを取り出せます
具体例
条件による型の出し分け
- Tがstringだったらnumber、それ以外ならbooleanに変換
type Test01<T> = T extends string ? number : boolean;
これを利用すると以下のようになります
type Test01A = Test01<string>; //number
type Test01B = Test01<object>; //boolean
- Tに関係なく、問答無用でnumberに変換(unknownはanyでも可)
type Test02<T> = T extends unknown ? number : boolean;
extendsの条件としてanyやunknown(対象がnever以外)を使うと、強制的に条件を成立させることが出来ます。これを利用するケースとしては、元の型に関わりなく構造を組み替えるような場合です。
利用例は以下のようになります。
type Test02A = Test02<string>; //number
type Test02B = Test02<object>; //number
特定の部分を取り出す
- 検証用に使うインタフェイスを作ります
interface TestIF01 {
a: { a0: number };
b: { b0: number };
}
- inferの利用と部分取り出し
type Test03<T> = T extends { a: infer R } ? R : never;
オブジェクトからaの中の型を取り出し、存在しなければneverが返ります
type Test03A = Test03<TestIF01>; //{a0:number}
type Test03B = Test03<{}>; //never
- キーを削除して値だけ取り出す
type Test04<T> = T extends { [_ in keyof T]: infer R } ? R : never;
オブジェクトからキーの部分を取りだすならkeyofを使えば済むのですが、プロパティ側の型を取りだすvalueofのようなキーワードが存在しないため、上記のように記述します。
オブジェクトのキーを確定せずに内容を取りだす際は
{[キー変数 in keyof キー型]:値}
という書き方をします。
type Test04A = Test04<TestIF01>; //{a0:number} | {b0:number}
キーを指定せずにオブジェクトの内容を取り出した場合、結果は共用体型となります。結果が交差型で欲しい場合もあるので、次の項目でやり方を紹介します。
共用体型を交差型に変換
- 変換方法
type Test05<T> = (T extends any ? (_: T) => void : never) extends (
_: infer R
) => void
? R
: never;
入力された型を関数の引数に変換した後、それを取りだすことによって共用体型を交差型へ変換しています。
type Test05A = Test05<Test04A>; //{a0:number} & {b0:number}
{a0:number} | {b0:number}
を{a0:number} & {b0:number}
に変換する中間過程は以下のようになります。
(_:{a0:number})=>void | (_:{b0:number})=>void
パラメータ単体ではなく、関数が共用体型となります。
(_:{a0:number} | {b0:number})=>void
ではないので注意してください。
ここから引数の型を取り出すと、関数の条件が成立するのは交差型のオブジェクトになるという原理です。回りくどいですが、組込みで変換機能が用意されていないのでこう書くしかありません。
構造を整形する
- 検証用に使うインタフェイスを作ります
interface TestIF02 {
200: { token: string };
500: { err: string };
}
上記のインタフェイスはRestAPI関連のツールで自動出力される型を簡略化した例です。
- 整形する
type Test06<T> = T extends unknown
? { [M in keyof T]: { code: M; value: T[M] } } extends {
[_ in keyof T]: infer R;
}
? R
: never
: never;
{code:コード,value:値}
という形に変換します
type Test06A = Test06<TestIF02>;
//{code:200,value:{token:string}} | {code:500,value:{err:string}}
共用体型でこのような構造を作ると、codeの値を判定した時点でvalueの型を決定するようなロジックが使用できます
if(result.code === 200){
result.value //{token:string}が確定
}else{
result.value //{err:string}が確定
}
複雑化する型の変換
今回紹介したのはtypeによる変換例で、あくまで入門レベルです。Genericsを関数のパラメータなどに設定して制約を付けたり、戻り値変換などを行おうとすると、変換工程が肥大化します。
以下はopenapi-typescript
というパッケージから吐き出したRestAPIの型データを利用して、パラメータや戻り値を生成する例です。実際の動作は大したことをしていないのに、型を付ける作業にコストの大半を持って行かれます。
import { paths } from '@/types/api'
const baseURL = process.env.NODE_ENV === 'development' ? 'http://localhost:4010' : '/api'
export const requestApi = <
T extends paths,
path extends keyof T,
method extends keyof T[path],
body extends T[path][method] extends { parameters: { body: { [key: string]: infer R } } }
? R
: never,
response extends T[path][method] extends { responses: infer res }
? {
[P in keyof res]: {
code: P
body: res[P] extends { schema: infer R } ? R : res[P]
}
} extends {
[P in any]: infer R
}
? R
: never
: never
>(
method: method,
path: path,
body?: body,
token?: string
): Promise<response> => {
return fetch(baseURL + path, {
method: method as string | undefined,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
},
body: body && JSON.stringify(body)
}).then(
async (res) =>
({
code: res.status,
body: await res.json()
} as response)
)
}
まとめ
型パズル楽しいです
Discussion