注目イベント!
アドベントカレンダー2024開催中!
一年を締めくくる特別なイベント、アドベントカレンダーを今年も開催しています!
初心者からベテランまで楽しめる内容で、毎日新しい技術トピックをお届けします。
詳細はこちらから!
event banner

事前に知っておきたいtype-challenges初級チートシート

| 9 min read
Author: shohei-yamashita shohei-yamashitaの画像

導入部

#

はじめに

#

type-challengesはTypeScriptの型システムを活用して複雑な型定義を解決するチャレンジ集です。
TypeScriptの型に関する理論や知識を知っていても、うまく使いこなせる自信がないという方は少なくないと思います。
そんな方向けに、問題演習を通してTypeScriptの応用力を養うことを目的として始まった試みがtype-challengesです。
詳細は以下のGithubのページからご参照ください。

本記事では初級(easy)に手を付ける前に筆者が知っておきたかった知識を、(独断と偏見で)まとめておきます。

書くこと/書かないこと

#

書くこと

#
  • 型パズル(初級)を突破するための、やや応用的な知識
    • Distributive Conditional Types
    • Mapped Types
    • infer
  • 型パズルの例題

書かないこと

#
  • TypeScriptの基本的な内容
  • 型パズルの問題と解答(ご自身の目で確認してください)

TypeScriptの基本

#

TypeScriptの基本については、この記事で網羅できません。弊社宇畑氏によるjavaエンジニアが始めるtypescript入門も併せてご参照ください。

実行環境について

#
TypeScipt: 5.7.2.
実行環境: TypeScript: TS Playground

バージョンはともかく、実行環境はなんでもいいと思います。ただ、ブラウザ上で検証できてかつエラーも即座に確認できるため、Playgroundをお勧めします。
リンク:TypeScript Playground

チートシート

#

Spread syntax

#

これはJavaScriptでも導入されていますが、スプレッド構文(...)は、配列やオブジェクトの要素を展開する構文です。

// 配列のスプレッド
const arr1 = [1, 2];
const arr2 = [...arr1, 3, 4]; // [1, 2, 3, 4]
// オブジェクトのスプレッド
const obj1 = { a: 1, b: 2 };
const obj2 = { ...obj1, c: 3 }; // { a: 1, b: 2, c: 3 }
// 関数の引数としても使用可能
function sum(...numbers: number[]) {
  return numbers.reduce((a, b) => a + b, 0);
}

なんと型でも使えます(はじめはここからつまずきました)。

Distributive Conditional Types

#

以下の例がわかる方は読み飛ばしてください。Test型はどんな型になるでしょうか?

type IsString<T> = T extends string ? true: false
type Test = IsString<"success" | 200>

Distributive Conditional Typesを直訳すると、分配された条件型という表現になります。
TypeScriptのリファレンス(Distributive Conditional Types)においては以下のように説明されています。

When conditional types act on a generic type, they become distributive when given a union type. For example, take the following:
条件型に対してユニオン型を与えると、ユニオン型が分配されて、Distributive Conditional Typesとなるといった解釈ができます。
以下に具体例を提示します。

type MyCommon<U, T> = U extends T ? U : never;
type Result2 = MyCommon<'a' | 'b' | 'c', 'a' | 'c' | 'd'> // 'a' | 'c'

このMyCommonは2つのユニオンの共通部分をユニオンとして抽出する型定義です。
この型がどのように機能しているのかを確認するため、実際の値に置き換えてみましょう。

type MyCommon<U, T> = U extends T ? U : never;
// = ('a' | 'b' | 'c') extends ('a' | 'c' | 'd') ? ('a' | 'b' | 'c') : never

ここで、条件型に対してユニオン型が与えられていることからDistributive Conditional Typesとなります。
具体的には以下のような分配が行われ、最終的にはユニオン型として定義されます。

type MyCommon<U, T> = U extends T ? U : never;
// = ('a' | 'b' | 'c') extends ('a' | 'c' | 'd') ? ('a' | 'b' | 'c') : never
// ↓
// ='a' extends ('a' | 'c' | 'd') ? 'a' : never  
//  | 'b' extends ('a' | 'c' | 'd') ? 'b' : never 
//  | 'c' extends ('a' | 'c' | 'd') ? 'c' : never
// ↓
// = 'a' | never | 'c'
// ↓
// = 'a' | 'c'

先ほどの例題の答えは、このルールに従うとbooleanとなります。

type IsString<T> = T extends string ? true: false
type test = IsString<"success" | 200>
// IsString<"success" | 200> 
//  = ("success" | 200 ) extends ? true : false
//  = "success" extends ? true : false | 200 extends ? true : false 
//  = true | false
//  = boolean

Mapped Types

#

以下の問題がわかる方はスキップしてください。
キーとバリューを入れ替えるような型であるMySwitchを作ってみましょう。

type ShortToLong =  {
  'q': 'search';
  'n': 'numberOfResults';
}
type TestRecord = { [P : string]: string; } // Record<string, string>でもOK
// type MySwitch<T extends TestRecord> = ?
type LongToShort = MySwitch<ShortToLong>
// Should be {'search': "q", "numberOfResults": "n"}

TypeScriptのリファレンス(Mapped Types)において、Mapped Typesは以下のように説明がされています。

When you don’t want to repeat yourself, sometimes a type needs to be based on another type.

すなわち、特定のタイプから別のタイプを作りたいときに使えるのがMapped Typesとなります。

一方、サバイバルTypeScrtipt(Mapped Types)ではユニオンから生成できるという主旨の説明がされています。

Mapped Typesは主にユニオン型と組み合わせて使います。

TypeScript本家の記述では「特定の型から別の型を作ること」を可能にするのがMapped Typesであるとされています[1]
ただ、実例を見るとTypeから別のTypeを生成するにも、ユニオン型を経由して生成しているように見えます。
本記事においては、説明の都合上、ユニオン型から別のタイプを生成するという方向性とさせてください。
頭に入れるべき構文は次のとおりです。

{[${任意の文字列} in ${基準となるユニオン型}] : ${タイプ}}

これだけだとわかりにくいので、いくつか例を提示します。

// {[${任意の文字列} in ${基準となるユニオン型}] : ${タイプ}}
type VectorMappedTypes = {[k in ('x' | 'y' | 'z')] : number}
// {'x': number, 'y': number, 'z': number}
type IdenticalMappedTypesXYZ = {[k in ('x' | 'y' | 'z')] : k}
// {'x': 'x', 'y': 'y', 'z': 'z'}
type IdenticalMappedTypes<T extends keyof any> = {[k in T] : k}
// ユニオンからキーとマップが一致する型を作成
type IdenticalMappedTypesXY= IdenticalMappedTypes<'x' | 'y'>
// {'x': 'x', 'y': 'y'}
type MyPickUnion<T, U extends keyof T>= {[k in U]: T[k]}
// 特定の型から、ユニオンにキーが含まれているもののみを抽出
type SampleType = {target: "a", other:"b", 23: "c"}
type test = MyPickUnion<SampleType, "other" | "target">
// {target: "a", other:"b"}

いずれの表現においても、共通して以下の構文が登場していますね。

{[${任意の文字列} in ${基準となるユニオン型}] : ${タイプ}}

Mapped Typesを利用すれば、先の例題は以下のように導かれるでしょう。

type ShortToLong =  {
  'q': 'search';
  'n': 'numberOfResults';
}
type LongToShort = { [k in keyof ShortToLong as ShortToLong[k]]: k }
type TestRecord = { [P : string]: string; } // Record<string, string>でもOK
type MySwitch<T extends TestRecord> = {[k in keyof T as T[k]]: k}
// as T[k]により、バリューとして上書きする
type LongToShort2 = MySwitch<ShortToLong>
// {'search': "q", "numberOfResults": "n"}

infer

#

個人的に最も理解に苦しんだのがinferです。
以下の問題が分かる人はスキップしてください。

const fn = (v: boolean) => {
   if (v)
     return "success"
   else
     return "error"
 }
 
 // type MyReturnType を求める
 
 type ResultString = MyReturnType<typeof fn> 
 // should be "success" | "error"

サバイバルTypeScrtipt(infer)においては以下のように書かれています。

inferはConditional Typesの中で使われる型演算子です。inferは「推論する」という意味でextendsの右辺にのみ書くことができます。

何を言っているのか正直理解できませんでしたが、色々調べてみると次のような記述を見つけました[2]

inferとは型推論によって決まる、一時的な型変数の宣言

inferを理解するために以下の型を見てみましょう。

type MyPickUnion<T, U extends keyof T>= {[k in U]: T[k]}
type SampleType = {target: "a", other:"b", 23: "c"}
type test = MyPickUnion<SampleType, "other" | "target">
// {target: "a", other:"b"}

先ほどのMapped Typesの章で出てきたもので、既に定義されている型から特定のキーの成分のみを抽出する型です。
このままだとオブジェクトが返されてしまいます。
そこで、キーバリューのうちバリューのみの抽出できる型を作ってみましょう。型の名前はMyPickValueとします。

type MyPickUnion<T, U extends keyof T>= {[k in U]: T[k]}
type SampleType = {target: "a", other:"b", 23: "c"}
type test = MyPickUnion<SampleType, "other" | "target">
// type MyPickValue = ?
type SampleTypePickedValue = MyPickValue<SampleType>
// Should Be "a" | "b"

ここではMyPickUnionを起点に考えます。
繰り返しになりますが、inferは一時変数の宣言であることを念頭においてください。
まず、一時変数としたいものをinfer Rとおきかえましょう。シンボルはRでなくても問題ないです。

// type MyPickUnion<T, U extends keyof T>= {[k in U]: T[k]}
// ↓
// 抽出したいものをinfer Rとする
type MyPickValue<T, U extends keyof T>= {[k in U]: infer R}

すると、以下のようなエラーが表示されるはずです。

'infer' declarations are only permitted in the 'extends' clause of a conditional type.

すなわち、extendsが含まれている条件型の中でしかinferは許されないと言われています。
まずはextendsを付け足してみます。次のように書き換えてみましょう。

// type MyPickUnion<T, U extends keyof T>= {[k in U]: T[k]}
// ↓
// 抽出したいものをinfer Rとする
// type MyPickValue<T, U extends keyof T>= {[k in U]: infer R}
// ↓
// T extendsを前段に置く
type MyPickValue<T, U extends keyof T>= T extends {[k in U]: infer R}

そもそも”?”や“:”がなく、条件型にはなっていない上、Rが未使用である旨のエラーが出ています。

'?' expected.
'R' is declared but its value is never read.

最後は、目的の値を返すように条件型を整えれば完成です。

// type MyPickUnion<T, U extends keyof T>= {[k in U]: T[k]}
// ↓
// 抽出したいものをinfer Rとする
// type MyPickValue<T, U extends keyof T>= {[k in U]: infer R}
// ↓
// T extendsを前段に置く
// type MyPickValue<T, U extends keyof T>= T extends {[k in U]: infer R}
// ↓
// ?や:を補って整える。返したいのはR
type MyPickValue<T, U extends keyof T>= T extends {[k in U]: infer R} ? R: never

いきなり最終系をみると困惑するかもしれませんが、ここまで順を追うと納得できるのではないでしょうか。
文法さえ間違っていなければ、TypeScript側で「推論」された型が返ってくるはずです。
章頭の例題も同様の流れで導出できそうです[3]

// 関数そのものの表現を返却し、ジェネリクスの中で検証してエラーが出ないことを確認する(任意)
// type MyReturnType<T extends (...args: any[]) => string> = T
// ↓
// 関数の表現をジェネリクスの外に出し、 = の右辺に持ってくる
// type MyReturnType<T> = (...args: any[]) => string
// ↓
// 取り出したいもの(今回はstring)をinfer Rに置き換える
// type MyReturnType<T> = (...args: any[]) => infer R
// ↓
// 条件型にして足りないシンボルを補う
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never 

長々と書きましたが、以下の3点がinferで重要なポイントです。

  1. inferはextends句を伴う条件型でしか使えない
  2. inferは一時変数のようなもの
  3. その型は型推論に基づいて決定される

慣れは必要ですが、使いこなせるようになりたいですね。

まとめ

#

今回は、type-challenges初級突破に必要な概念のうち、基本から外れてそうなものをまとめてみました。
この記事を取っ掛かりにしてtype-challengesに挑戦してくれる方々が増えれば喜ばしい限りです。


  1. 前述した弊社宇畑氏の記事でも「既存の型から新しい型」を定義する旨が書かれています ↩︎

  2. 参考記事:https://zenn.dev/axoloto210/articles/advent-calender-2023-day25 ↩︎

  3. さらっと流していますが、スタートとして使える表現を導出するまでが大変かもしれません。 ↩︎

豆蔵では共に高め合う仲間を募集しています!

recruit

具体的な採用情報はこちらからご覧いただけます。