TypeScriptでobjectのunion型から型を抽出する
概要
type SuccessResult = {
status: "success"
}
type ErrorResult = {
status: "error"
errorCode: number
errorMessage: string
}
type AbortResult = {
status: "abort"
}
export type Result = SuccessResult | ErrorResult | AbortResult
↑このような型が定義してあったとして
declare const result: Result
result.errorCode // error! プロパティ 'errorCode' は型 'Result' に存在しません。
if (result.status === "error") {
// result is ErrorResult
result.errorCode // ok!
}
型ガード(具体的にはDiscriminated unions)によって、result.status === "error"
の時result
はErrorResult
型として扱うことができます。
しかし、デフォルトのtsの型機能だけでResult
型のようなunion型から特定の型を抽出することはできません。
そこで、
- objectのunion型
- keyとなるプロパティ名(string literal型)
- 特定ののkeyと一致する名称(string literal型)
上記の3つの型を指定して、union型から特定の型を抽出する型ハックを実装します。
実装
ts 4.5.4 で確認しています。(全体のソースのプレイグランド)
type FindFromUnion<
Target extends {},
KeyProp extends keyof Target,
Key extends Target[KeyProp]
> = Target extends { [x in KeyProp]: Key } ? Target : never;
別の書き方
type FindFromUnion<
Target extends {},
KeyProp extends keyof Target,
Key extends Target[KeyProp]
> = Extract<Target, Record<KeyProp, Key>>
type ErrorResult2 = FindFromUnion<Result, "status", "error">
// ErrorResult2 is ErrorResult
FindFromUnion<Result, "status", "error">
と書くことで、Result
型からErrorResult
型を抽出することができました。
いつこれが役に立つのか
上記のようなような使い方は元のErrorResult
型をexportすれば解決するのであまりしないと思います。
いつFindFromUnion
型が役に立つかというと、Mapped typesを使って型を動的に作るときに役に立ちます。
Mapped typesについて解説
// Result["status"] = "success" | "error" | "abort"
type ResultMap = {
[ResultStatus in Result["status"]]: ResultStatus
}
/*
ResultMap is {
success: "success";
error: "error";
abort: "abort";
}
*/
最初に出てきたResult
型を使って、上記のようなResultMap
型が作れます。
ポイントは、ResultStatus
がそれぞれ、"success"
型、"error"
型、"abort"
型になることです。
これにFindFromUnion
型を使うと、
type ResultMap = {
[ResultStatus in Result["status"]]: FindFromUnion<Result, "status", ResultStatus>
}
/*
ResultMap is {
success: SuccessResult;
error: ErrorResult;
abort: AbortResult;
}
*/
このように、union型のobjectから、Result["status"]
をkeyとした別の型が作れます。
もちろん、オプショナルや関数にすることもできます
type ResultFuncMap = {
[ResultStatus in Result["status"]]?: (result: FindFromUnion<Result, "status", ResultStatus>) => void
}
/*
ResultFuncMap = {
success?: ((result: SuccessResult) => void) | undefined;
error?: ((result: ErrorResult) => void) | undefined;
abort?: ((result: AbortResult) => void) | undefined;
}
*/
以下の例では、Mapped typesを使用してResultFuncMap
型を定義し、createResultHandler
関数を実装することで、パターンマッチのようなヘルパー関数を型安全に実装することができます。
type ResultFuncMap = {
[ResultStatus in Result["status"]]?: (result: FindFromUnion<Result, "status", ResultStatus>) => void
}
/*
ResultFuncMap = {
success?: ((result: SuccessResult) => void) | undefined;
error?: ((result: ErrorResult) => void) | undefined;
abort?: ((result: AbortResult) => void) | undefined;
}
*/
function createResultHandler(handlerMap: ResultFuncMap): (_: Result) => void {
return (result) => {
// キャストしないとエラーになる
const func = handlerMap[result.status] as ((_: Result) => void) | undefined
func?.(result)
}
}
const handler = createResultHandler({
success() {
console.log("case success")
},
error({ errorCode, errorMessage }) {
// ↑ 引数の型がちゃんと`ErrorResult`型から推測されている
console.log("case error", { errorCode, errorMessage })
},
abort() {
console.log("case abort")
},
})
handler({
status: "success",
})
// → case success
handler({
status: "error",
errorCode: 401,
errorMessage: "Unauthorized",
})
// → case error { errorCode: 401, errorMessage: 'Unauthorized' }
handler({
status: "abort",
})
// → case abort
補足
function createResultHandler(handlerMap: ResultFuncMap): (_: Result) => void {
return (result) => {
// キャストしないとエラーになる
const func = handlerMap[result.status] as ((_: Result) => void) | undefined
func?.(result)
}
}
上記の箇所はキャストしないとエラーになります。
FindFromUnion
型を使う機会はあまりなさそうですが、Mapped typesと合わせて使うととても強力なので是非使ってみてください!
Discussion