弁護士ドットコム株式会社 Creators’ blog

弁護士ドットコムがエンジニア・デザイナーのサービス開発事例やデザイン活動を発信する公式ブログです。

型パズルを理解しTypeScript中級者になる8のポイント

 この記事は、弁護士ドットコム株式会社の Advent Calendar 2023 の 22 日目の記事です。

前日は @et_tei さんの「FireHOL で公開されているブラックリストからの接続 Akamai でブロックする」でした。


こんにちは。税理士ドットコム事業部の @komtaki です。

数年間 TypeScript を業務で使っていたのですが、型パズルの Type Challenges をやってようやく TypeScript の本質を理解していないことに気づきました。

この記事では Type Challenges を通して、私のような型パズルが難しいと感じる人のために型パズルを理解するための言語機能と使い方を 8 つのポイントでまとめます。

この 8 つのポイントを理解すれば、いろんな型パズルが理解できるようになるはずです。

Type Challenges とは TypeScript の型システムに関する問題集を提供する OSS で、型システムの理解を深められます。

github.com

なおこの記事の型パズルとは TypeScript の型のみを使い複雑な機能や構造を表現する手法と定義します。そして読者はすでに基本的な Generics や readonly、リテラル型、ユニオン型、primitive 型は理解している前提とします。

各ポイントの最後に、できる限り対応しそうな Type Challenges の Easy の問題を紹介しています。理解したと思ったら、最後に挑戦してみてください。

8つのポイント

1. extendsによるnarrowing

extends はクラスやインタフェースを継承するためのキーワードです。多くの言語であるように、extends で既存のクラスやインタフェースを拡張できます。

interface Animal {
  move: () => void
}

interface Dog extends Animal {
  bark: () => void
}

しかしここでは、extendsを利用して型を絞り込む(narrowing)ことにフォーカスします。

TypeScript におけるnarrowingは、コンパイラが特定の条件で型を絞り込む(より具体的な型に制約する)仕組みのことです。条件分岐や特定の操作によって、変数や値の型をより具体的な型に絞り込みできます。

またジェネリックスと併用して、汎用的な型や関数を作成するため特定の型を決め打ちせず、柔軟性を持たせられます。

もちろんnarrowingtypeofinstanceofin演算子でもできますが、ここでは型パズルでつかう extends を取り上げます。

下記の例では、Generics の引数Tstring or numberであるという制約を表現しています。

type Form<T extends string | number> = {
  value: T
}

type TextForm = Form<string> // ok
type InvalidForm = Form<boolean> // ts-error

これだけだと基本的な内容ですが、Conditional Types と組み合わせることで本当の力を発揮します。次からが本番です。

これを理解するだけで、Type Challenges の Concat や Push が解けるので挑戦してみましょう。モダンな JS を書いていれば、extends を使って雰囲気で解けるはずです。

github.com

github.com

2. Conditional Types(条件付き型)

Conditional Types(条件付き型)は、条件に応じて型を選択するための機能です。この機能で、TypeScript は条件ごとに異なる型を推論できます。

残念ながら型なので=などの演算子は使えません。多くの場合T extends U ? X : Yのような形式で表し、Tが型Uを満たす場合はX型を、満たさない場合はY型を表現します。

例えば、次のように使います。

type Check<T> = T extends string ? boolean : number;

type Value = Check<string>; // boolean
type NumberValue =  Check<number>; // number

Value は Generics の引数Tstringで、当然stringstringと同じ型(extends している)なので boolean になります。一方 NumberValue は Generics の引数Tnumberで、numberstringを extends していないため number になります。

これを理解すれば、Type Challenges の If が解けるので挑戦してみましょう。

github.com

3. Distributive Conditional Types(ユニオン型の分配法則)

Distributive Conditional Types(ユニオン型の分配法則)は、Conditional Types がジェネリック型に使われる時、ユニオン型の各メンバーに対して条件が適用されることです。

type Check<T> = T extends string ? boolean : number;

type Result = Check<string | number>;
// (string extends string ? boolean : number) | (number extends string ? boolean : number)
// (boolean) | (number)

この例では、Checkという条件型を使ってstring | numberに適用します。Check<string | number>が評価される際、stringnumberのそれぞれに条件型が適用され、その結果がユニオン型になります。

ここで重要なのは、条件付き型がジェネリック型に適用された場合、Tがユニオン型の場合、その各要素に対して条件が個別に判定されること。これが分配法則の本質です。

4. never

neverは絶対に到達不可能なコードを示す場合や、関数が例外を投げて戻り値を返さない場合に使います。

似た型として void がありますが、大きな違いがあります。

  • void型は、何も返さないことを示すために使われますが、関数は正常に終了します。
  • never型は、絶対に値を返さない関数や、絶対に終了しない関数(無限ループや例外投げる関数)を表します。

型パズルにおいて、neverは Conditional Types と併用し条件に合致した場合、型を返さないというケースで使います。

type ExcludeFalsy<T> = T extends false | null | undefined ? never : T;

type NonFalsyString = ExcludeFalsy<string | null | undefined | false>;

この例では、ExcludeFalsy型はTfalsenullundefinedのいずれかである場合はnever型、それ以外の場合はTそのものの型を返します。

NonFalsyStringstring | null | undefined | falseという型を持っていますが、ExcludeFalsyを使うことでfalsenullundefinedを除外し、結果としてstring | never型のみが残ります。 そしてnever型にはどんな値もセットできないため、 string | neverstring になります。

これを理解すれば、Type Challenges の Exclude が解けるので挑戦してみましょう。

ヒント:分配法則を使います。

github.com

5. infer

inferは、Conditional Types 内で型変数を抽出するために使うキーワードです。

type ExtractType<T> = T extends Array<infer U> ? U : never;

type ArrayElementType = ExtractType<string[]>; // string

この例では、ExtractTypeという条件付き型を定義しています。この型は、Tが配列型である場合、その配列の要素型をinfer Uを通じて抽出し、Uを返します。そして、ArrayElementTypeではExtractTypeを使ってstring[]の要素型であるstringを抽出しています。

これを理解すれば、Type Challenges の First が解けるので挑戦してみましょう。

github.com

6. key of

keyofは、オブジェクトのキー(プロパティ名)を取得するキーワードです。keyofを使うと、オブジェクトの型からプロパティ名を取り出し、それらをユニオン型として表現できます。

type Person = {
    name: string;
    age: number;
    email: string;
};

type PersonKeys = keyof Person; // "name" | "age" | "email"

7. Map types

マップ型(Map Types)は、既存の型を操作し新しい型を生成する機能で、既存の型の各プロパティを変更、追加、削除できます。

マップ型は、{ [K in Keys]: ValueType } のような形式で表現します。

type Person = {
    name: string;
    age: number;
};

// すべてのプロパティをオプショナルにする
type PartialPerson = {
    [K in keyof Person]?: Person[K];
};

const partialData: PartialPerson = {}; // { name?: string; age?: number; }

この例では、PartialPersonというマップ型を使い、Person型の各プロパティをオプショナルにしています。[K in keyof Person]?: Person[K]の部分は、Person型の各プロパティをループし、それぞれのプロパティをオプショナルに変更して新しい型を作っています。

これを理解すれば、Type Challenges の pick が解けるので挑戦してみましょう。

github.com

8. 再帰処理

なんと再帰処理も型でできます。TypeScript における型の再帰処理は、型定義内で自己参照し、複雑なデータ構造やパターンの表現を可能にします。

これにより、木構造やリスト、グラフなどの再帰的なデータ構造を表現し、型を柔軟に操作できます。

type TreeNode<T> = {
    value: T;
    left?: TreeNode<T>;
    right?: TreeNode<T>;
};

// 数値を持つTreeNode型の宣言
const numericNode: TreeNode<number> = {
    value: 10,
    left: {
        value: 5,
        left: {
            value: 3,
        },
        right: {
            value: 8,
        },
    },
    right: {
        value: 15,
    },
};

これを理解すれば、Type Challenges の Awaited が解けるので挑戦してみましょう。

github.com

まとめ

いかがだったでしょうか。TypeScript の深淵を少しのぞけたのではないでしょうか。

正直、このレベルの知識を普段の業務で使う機会は少ないですが、ちょっとしたライブラリを作るときとか凝ったことしたいときにはきっと役に立つはずです。

ぜひ Type Challenges で紹介できなかった他の問題にも挑戦してみてください。easy の中には、難易度を疑う問題もあるので、ご注意を。

私もまだ medium に挑戦している途中なので、一緒に頑張りましょう。


明日の弁護士ドットコム株式会社の Advent Calendar 2023 の担当は クラウドサイン事業本部の篠田(@tttttt_621_s) さんの「Axios における request method の引数の型定義を調査した話」です。お楽しみに。