🤔

TypeScriptでobjectのunion型から型を抽出する

2022/02/09に公開

概要


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"の時resultErrorResult型として扱うことができます。

しかし、デフォルトの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