🧒

明日から使えるTypeScriptの応用テクニックその1 -Mapped/ConditionalTypes編-

2023/10/31に公開
1

久しぶりに技術記事を書くということで、三部に分けて小ネタをやっていきたいと思います。
今回はちょっと趣向を変えて、ワイ記法™でお送りします。

https://qiita.com/Yametaro/items/a40ffcd65d77ed8b29a0

Mapped Types

(ある日のこと)

娘(7歳)「パパ、ちょっとお願いがあるの」

ワイ「どうしたんや、娘ちゃん?」

娘「あのね、オブジェクトのすべてのプロパティを大文字に変換する関数を作ってほしいの」
娘「例えば...」

{
  name: "musume-chan",
  skill: "malicious-tongue",
}

娘「っていう引数があったら」

{
  name: "MUSUME-CHAN",
  skill: "MALICIOUS-TONGUE",
}

娘「っていう戻り値を返してほしいの」
娘「まだ学校で習ってないから、アルファベット大文字の書き方がわからないの」
娘「だからお願い!」

ワイ「なるほど」
ワイ「よっしゃワイに任しとき!」

(数分後)

ワイ「できたで!」
ワイ「こうや!」

const convertValuesToUpperCase = (obj: Record<string, string>): Record<string, string> => {
  return Object.keys(obj).reduce(
    (accum: Record<string, string>, current: string) => ({
      ...accum,
      [current]: obj[current].toUpperCase(),
    }),
    {},
  )
}

娘「パパ、それだとTypeScriptの型がいい感じにならないの

ワイ「ぐぬぬ」
ワイ「つまり・・・」

const myProfile = {
  name: "musume-chan",
  skill: "malicious-tongue",
} as const

const converted = convertValuesToUpperCase(myProfile)

// 🤔 実際のconvertedの型
Record<string, string>

// 😆 こうなってほしいconvertedの型
{
  readonly name: "MUSUME-CHAN",
  readonly skill: "MALICIOUS-TONGUE",
}

ワイ「ってことやな!」
ワイ「そんなのできるわけ・・・」

よめ子「どないしたんや」

ワイ「娘ちゃんが、大文字が書けへんから全部大文字に変換する関数つくってほしいらしいねん」

よめ子「(どういうことやねん・・・)」
よめ子「できるで」

ワイ「ふぁっ!!?」

よめ子「Mapped Typesっていうのを使えばええんや」

ワイ「マックでタイポす・・・?」
ワイ「ワイやないか!」

よめ子「いや、あんたはどこでもタイポしてるやろ」
よめ子「あんな、Mapped Typesっていうのは・・・」
よめ子「あ、私が説明せんでも、やめ太郎さんがわかりやく記事にしてくれてはるわ」

https://qiita.com/Yametaro/items/6b84de69fbe4e91e2053

ワイ「ふむふむ」
ワイ「さすがやめ太郎さん、本物の関西型言語や・・・」

ハスケル子「ええと、タイトルは」
ハスケル子「・・・吾輩はバグを出す・・・?」
ハスケル子「なるほど」
ハスケル子「ノンフィクション小説ですね?」

ワイ「どういう意味やねん」
ワイ「フィクションや、フィクション」

ワイ「こんなん絶対わろてまうやろ」

ワイ「ある型に対応した、オブジェクト型を定義する・・・」
ワイ「Mapped typesを使うとそんなことができるんやな」

ハスケル子「そうですね」
ハスケル子「mapという単語には」
ハスケル子「対応づけるとか対応表って意味もありますからね」

ワイ「なるほど」
ワイ「つまり、Mapped Typesは型の対応表ってことやな」
ワイ「[P in keyof T]: T[P]っていうのは、PとT[P]、つまりオブジェクトのkeyとvalueが対応してるってことか💡」

ワイ「ワイが今回したいのは、オブジェクトのkeyと、大文字化されたvalueの対応を型で表現したいってことや」
ワイ「大文字化されたvalue・・・どうしたらええんや・・・?」

よめ子「Uppercaseっていう組み込み型関数が使えそうじゃない?」

ワイ「それや!Mapped TypesとUppercaseを使って・・・」

const convertValuesToUpperCase = <T extends Record<string, string>>(obj: T): { [K in keyof T]: Uppercase<T[K]> } => {
  return Object.keys(obj).reduce((accum, current) => {
    return {
      ...accum,
      [current]: obj[current].toUpperCase(),
    }
  }, {} as { [K in keyof T]: Uppercase<T[K]> })
}

ワイ「ってことやな!」
ワイ「娘ちゃんできたで」

娘「わーい、パパ、ありがと〜」

Conditional Types

(次の日)

娘「パパ、あのね、お願いがあるの」
ワイ「なんや娘ちゃん、またかいな」

娘「パパに昨日つくってもらった関数だけど」
娘「プロパティに数字があっても変換できるようにしてほしいの

ワイ「なるほど」

const myProfile = {
  name: "musume-chan",
  skill: "malicious-tongue",
  age: 7,
} as const

ワイ「・・・っていうのも」

const myProfile = {
  name: "MUSUME-CHAN",
  kill: "MALICIOUS-TONGUE",
  age: 7,
} as const

ワイ「にしてほしいちゅうことやな」
娘「TypeScriptの型もいい感じにしてほしいの」

// ✅ こうなってほしい戻り値の型
 {
  readonly name: "MUSUME-CHAN",
  readonly skill: "MALICIOUS-TONGUE",
  readonly age: 7,
}

娘「↑こんな感じにして」
ワイ「ぐぬぬ、いくらパパでもそんなのできるわけ・・・」

よめ子「できるで」

ワイ「ふぁっ!!?」

よめ子「Conditional Typesっていうのを使えばええんや」

ワイ「コンディショナーでタイポす・・・?」
よめ子「・・・」
よめ子「いや、あんたの場合、シャンプーでもボディソープでもなんでもタイポしとるで」

よめ子「あんな、Conditional Typesっていうのは・・・」
よめ子「あ、私が説明せんでも、やめ太郎さんがわかりやく記事にしてくれてるわ」

https://qiita.com/Yametaro/items/9b2f0ab2037450004816

ワイ「やめ太郎さん、さすが何でも書いてくれてはるな」

  • A extends B ? C : Dという形式で、条件分岐のロジックをもった型を作れる
    • A extends Bが条件にあたる
    • Cは真の場合に返す型
    • Dは偽の場合に返す型

ワイ「ふむふむ、なるほど」
ワイ「つまりConditionalTypesは、型における三項演算子みたいなもんちゅうことやな」
ワイ「T extends U ? X : Yっていう構文で、TがUの部分型であるときはXを返して、そうでないときはYを返してくれる、と」

娘「部分型・・・?」

ワイ「部分型っていうのは、なんちゅうのかな、自分より大きな集合に含まれるかどうかってイメージやな」

partial-type

ワイ「っていう関係のとき、TがUの部分型である、って考えたらいいと思う」[1]

娘「ふーん」
娘「それって、、私がまだ母親のお腹の中にいた頃、私はまだ母親の一部であり母親の部分型であった。しかし、情緒的成熟を果たし、独自の自我を獲得した今となっては私はもはや母親の部分型とはいえない...」
娘「・・・みたいなこと?」

ワイ「・・・そ、そういうことになるな・・・?」
ワイ「もっと具体的な例を出すと、、、」

"musume-chan" extends string ? true : false // → true
"musume-chan" extends number ? true : false // → false

type WaiFamily = "musume-chan" | "yome" | "wai"
"musume-chan" extends WaiFamily ? true : false // → true

type Vehicle = "motorbike" | "car" | "train"
"musume-chan" extends Vehicle ? true : false // → false

"musume-chan" extends any ? true : false // → true

ワイ「って感じや!」[2]

ワイ「よし、じゃあこれを使って・・・」

const convertValuesToUpperCase = <T extends Record<string, unknown>>(
  obj: T,
): { [P in keyof T]: T[P] extends string ? Uppercase<T[P]> : T[P] } => {
  return Object.keys(obj).reduce(
    (accum, current) => {
      const value = obj[current]

      return {
        ...accum,
        [current]: typeof value === "string" ? value.toUpperCase() : value,
      }
    },
    {} as {
      [P in keyof T]: T[P] extends string ? Uppercase<T[P]> : T[P]
    },
  )
}

ワイ「ってすればええんや!」
ワイ「これでちゃんとコンパイルエラーにならずにageも渡せて、戻り値の型もええ感じや」

娘「・・・パパ、あのね、だからパパは女の子にモテないの

ワイ「ふぁっ!!!!!?」

娘「普通女の子から年齢を聞いたら、男の子はそれをundefinedに変えてあげるものなの」
娘「それが紳士の嗜みなの」
娘「レディは年齢を知られたくない生き物なの」

ワイ「なるほど、そうやったんか・・・」
ワイ「つまり・・・」

const musumeChanProfile = {
  name: "musume-chan",
  gender: "female",
  age: 7,
} as const

const waiProfile = {
  name: "wai",
  gender: "male",
  age: 30,
} as const

ワイ「っていう引数を受け取ったら、それぞれ、」

const musumeChanProfile = {
  name: "MUSUME-CHAN",
  gender: "FEMALE",
  age: undefined,
} as const

const waiProfile = {
  name: "WAI",
  gender: "MALE",
  age: 30,
} as const

ワイ「って変換してあげたいってことやな」
ワイ「よく考えたら、娘ちゃんの言う通りそれが紳士の嗜みやったわ」
ワイ「よっしゃ任しとき!」

(数時間後)

ワイ「確かConditionalTypesは、普通の三項演算子と同じように、ネストさせることができるねん」
ワイ「ちょっと見づらくなってしもうけどな」

(数日後)

ワイ「せやから、うーん、これをこうしてこうか・・・??」

(数ヶ月)

ワイ「できたで!」

const convertValuesToUpperCase = <T extends Record<"gender", "female" | "male"> & Record<string, unknown>>(
  obj: T,
): T extends { gender: "female" }
  ? { [P in keyof T]: T[P] extends number ? undefined : T[P] extends string ? Uppercase<T[P]> : T[P] }
  : { [P in keyof T]: T[P] extends string ? Uppercase<T[P]> : T[P] } => {
  return Object.keys(obj).reduce((accum, current) => {
    const value = obj[current]

    if (obj.gender === "male") {
      return {
        ...accum,
        [current]: typeof value === "string" ? value.toUpperCase() : value,
      }
    }

    return {
      ...accum,
      [current]: typeof value === "string" ? value.toUpperCase() : undefined,
    }
  }, {} as T extends { gender: "female" } ? { [P in keyof T]: T[P] extends number ? undefined : T[P] extends string ? Uppercase<T[P]> : T[P] } : { [P in keyof T]: T[P] extends string ? Uppercase<T[P]> : T[P] })
}

ワイ「ちゃんと戻り値の型もバッチリ決まるようにしといたで」

converted1
converted2

娘「わーい、パパありがとう、THANK YOU〜」

ワイ「ワイが悩んでる間に、娘ちゃん、アルファベット覚えてしもうてるやん・・・」
ワイ「まあ、おかげで勉強になったからええわ」

あとがき

今回は、ワイ記法™で技術記事を書くことに挑戦してみましたが、いやぁ関西型言語は実に奥が深い...。
しかし、

ワイ「マックでタイポす・・・?」

のくだりは、我ながらワイ記法™の真髄にかなり肉薄できたのではないでしょうか?

とはいえ、本物の関西人はマクドナルドをマクドと呼びます。マクドナルドをマックと呼んでしまうようでは、まだまだエセ関西型言語といえるでしょう。

やはりこの記法は、私が関西型言語を真にマスターするその日まで封印しておきたいと思います。勝手にお借りしてすみません🙇‍♂️

補足

ワイ記法™でお送りする内容は以上ですが、最後に二点ほど補足があります!

複雑な型はいざというときに役に立つが使い所に注意

今回、説明のために、便宜上、convertValuesToUppercaseというメソッドを用意しました。
しかし、自分で書いておいてあれなんですが、あまり良い関数とは思えません。
こんな関数をわざわざ作らなくても、

const myProfile = {
  name: "musume-chan",
  skill: "malicious-tongue",
  age: 7
} as const

const converted = {
  ...myProfile,
  name: myProfile.name.toUpperCase() as UpperCase<typeof myProfile["name"]>,
  skill: myProfile.skill.toUpperCase() as UpperCase<typeof myProfile["skill"]>
} as const

くらいにしておけば、ほとんどのケースで十分だと思います。場合によっては、これでもやりすぎかもしれません。
また、いくらより厳密な型を得るためだっとしても、

T extends { gender: "female" }
    ? { [P in keyof T]: T[P] extends number ? undefined : T[P] extends string ? Uppercase<T[P]> : T[P] }
    : { [P in keyof T]: T[P] extends string ? Uppercase<T[P]> : T[P] }

というような型定義は、さすがに可読性が悪いです。
この点については、コメントを書くなりして、複雑な型の意味を補足しておくと良いと思います。
やはり、もっとシンプルな実装がなかったのか、というのも考えるべき点です。

そして、可読性の悪さにもましてもっと大きなデメリットがあります。
convertValuesToUpperCaseの戻り値については厳密な型が得られたのですが、その代償としてconvertValuesToUpperCaseの内部の型安全を犠牲にしてしまったことです。
例えば、

const convertValuesToUpperCase = <T extends Record<string, string>>(obj: T) => {
  Object.keys(obj).reduce(
    (accum: { [P in keyof T]: Uppercase<T[P]> }, current: keyof T) => ({
      ...accum,
      [current]: obj[current].toUpperCase(),
    }),
    {} as { [P in keyof T]: Uppercase<T[P]> },
  )
}

と書くべきところ、うっかり.toUpperCase()を忘れて、

const convertValuesToUpperCase = <T extends Record<string, string>>(obj: T) => {
  Object.keys(obj).reduce(
    (accum: { [P in keyof T]: Uppercase<T[P]> }, current: keyof T) => ({
      ...accum,
+     [current]: obj[current],
    }),
    {} as { [P in keyof T]: Uppercase<T[P]> },
  )
}

と書いてしまっても、コンパイルエラーにはなりません。

これでは、戻り値の型定義が嘘になってしまい、非常に危険なコードです。この点については、テストを書くなりして、十分に注意が必要です。

以上のように、複雑な型を使うことにはデメリットも伴います。したがって、複雑な型定義はここぞ!というときにこそに使うのがおすすめです。

Mapped TypesやConditionalTypesのメリット

上の注意事項を読むと、なんだか「Mapped TypesもConditional Typesも使わない方がいいんじゃね...?」という気持ちがしてくるかもしれません。しかし、そんなことはありません。要は適材適所です。

それでもMapped TypesやConditionalTypesを知っておくと、

  • 組み込み型関数が使いこなせる
  • いざというときに役に立つ
  • (ライブラリの型定義が読める)

といったメリットがあります。

特に、組み込み型関数についていうと、TypeScriptの組み込み型関数はMapped TypesやConditional Typesでできているものが多くあります。
組み込み型関数の実際のソースコードは、

https://github.com/microsoft/TypeScript/blob/main/src/lib/es5.d.ts

にあるので覗いてみると楽しいです。
↑のファイルで、今回も度々登場したRecordという型関数は、

type Record<K extends keyof any, T> = {
    [P in K]: T;
};

となっています。
これは、Mapped Typesをちょっとだけ扱いやすくした便利な型関数です。Record<string, number>と書けば、それはkeyがstring、valueがnumberのオブジェクトを表しています。

また、記事には書きませんでしたが、Mapped Typesには二種類のマッピング修飾子というものがあります。readonly?です。これらは、+を付けることで追加が可能であり、-を付けることで削除が可能です。(ただし、+は省略することができます)

具体的には、

// オブジェクトのプロパティをすべてreadonlyにする
type SomeUseFullUtility1<T> = {
    +readonly [P in keyof T]: T[P];
};

// オブジェクトのプロパティをすべてoptionalにする
type SomeUseFullUtility2<T> = {
    [P in keyof T]?: T[P];
};

// オブジェクトのプロパティをすべてrequiredにする
type SomeUseFullUtility2<T> = {
    [P in keyof T]-?: T[P];
};

のように使うことができます。

お察しと思いますが、これらがぞれぞれ組み込み型関数のReadonly,Partial,Requiredです。

このように、組み込み型関数がどのように成り立っているかを知ると、組み込み型関数をうまく使いこなすことができます。ここに挙げたReadonlyPartialRequiredも、ここに挙げなかったNonNullableExcludeExtractPickOmitも、Mapped TypesとConditional Typesさえ覚えておけば、使い方をすべて暗記する必要はありません。その型定義に目を通すだけで、組み込み型関数の使い方がすんなり把握できるようになります。

また、色々と応用も効くようになります。例えば、欲しい組み込み型関数がないときに、

// オブジェクトのプロパティをすべてwritableにする
type Writable<T> = {
    -readonly [P in keyof T]: T[P];
};

のようなオリジナルの型関数を作成することも可能になります。

おわり

以上です。
今回は意外と役に立つMapped TypesとConditional Typesについて、実例とともにお送りしました。次回「明日使えるTypeScriptのテクニックその2」では、より実践的な型を見ていきたいと思います。

では、一旦ここまで!
読んでいただきありがとうございました。
(筆者はTypeScript初心者なので、もし間違いがありましたらお優しめにご指摘ください🙏)

...。

......。

ワイ「・・・というか、そもそも」
ワイ「ワイには、嫁も娘もおらへんかったわ・・・」

その2に続きます☞

https://zenn.dev/t_keshi/articles/tips-typescript-2

脚注
  1. 部分型というより部分集合の説明みたいになってしまいましたが、あくまでイメージとしてはそんなに間違ってない......はず。 ↩︎

  2. これには自分の知っている限り2つほど例外的な挙動があります。一つはanyで、any extendsとしたときは、trueでもあるしfalseでもあるみたいな特殊な動きになります。Ambiguous Result for Any Conditional Type Mappingをご参照ください。もう一つはneverで、never extendsとしたときは、ゼロUnion分配とでも呼ぶべき特殊な現象が起きて問答無用でneverが返ります。T がneverの時の、T extends .. は、問答無用でneverになるをご参照ください。 ↩︎

GitHubで編集を提案

Discussion

砂漠砂漠

いままでRuby、JavaScript、Pythonと動的型付け言語ばっかやってきたんで静的型付けの難しさに驚愕してる