💯

最低限これさえ押さえておけば、TypeScriptの型生成はサクッとできるよ集(Type Challenges Easy レベル)

2024/08/26に公開

相変わらず暑い夏ですね。TypeScriptでコードを書いていると、既存の型や組み込みの型ユーティリティでは解決できない問題に直面することが頻繁にあるかと思います。そんなとき、サクッと独自の型を生成して解決したいものです。
そうでないとこの暑さと相まっていろいろな面でいろいろヒートアップアイランドしてしまいます。

そこで今回は、Type ChallengesのEasyレベルに相当する型であれば、スムーズに作成できるようになるための、最低限知っておきたい知識を取り上げます。
知っている内容も多いかと思いますが、意外と理解が曖昧な部分があるかもしれません。復習の意味も込めて、ぜひお読みいただければと思います。

この記事を読めば、型生成が今までよりすらすらとできるようになるでしょう。(保証します)
そして暑い夏も少しはマシになるでしょう。(保証はしません)

それでは、さっそく始めましょう!

ジェネリクス

ジェネリクスは、型をパラメータとして受け取ることができる機能です。ジェネリクスを使用すると、さまざまな型に対して機能する柔軟な関数やクラスを作成できます。

基本

ジェネリクスを使用しない場合、特定の型に依存する関数は次のようになります。

function notGenericsFunction(value: number): number {
  return value;
}

この関数はnumber型に限定されていますが、同じロジックをstringや他の型に対しても使いたい場合があります。ここでジェネリクスが役立ちます。

function genericsFunction<T>(value: T): T {
  return value;
}

このgenericsFunction関数は、任意の型Tを受け取ります。関数を呼び出すときに、Tの具体的な型が決まります。

const num = genericsFunction<number>(42); // T は number
const str = genericsFunction<string>("hello"); // T は string

TypeScriptは通常、ジェネリクスの型を推論できます。

const num = genericsFunction(42); // T は number と推論される
const str = genericsFunction("hello"); // T は string と推論される

もちろん複数の型パラメータを持つこともできます。

function pair<T, U>(first: T, second: U): [T, U] {
  return [first, second];
}

const result = pair<string, number>("hello", 42); // [string, number]

ジェネリクスの制約

ジェネリクスに制約を設けて、特定のプロパティやメソッドを持つ型に限定することができます。
この機能は型生成のときによく使うので、覚えておいてください。制約を設けて型を絞り込むイメージですね。

interface Lengthwise {
  length: number;
}

function logLength<T extends Lengthwise>(value: T): void {
  console.log(value.length);
}

logLength("hello"); // 5 と出力される
logLength([1, 2, 3]); // 3 と出力される

ここでは、Tがlengthプロパティを持つことを保証しています。そのため、stringやArrayなどlengthプロパティを持つ型のみが使用できます。

keyof

keyofはオブジェクトのキー(プロパティ名)の集合を型として取得するためのユーティリティ型です。keyofを使用することで、オブジェクトのプロパティ名に基づいた型を作成することができます。

基本

keyofを使うと、オブジェクト型のすべてのキーをユニオン型として取得できます。

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

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

この例では、PersonKeys型は、"name" | "age" | "address"というユニオン型になります。

keyofを使用した関数

先述したジェネリクスの制約とkeyofを利用して、特定のキーに基づいてオブジェクトから値を取得する関数を作成できます。

const getProperty = <T, K extends keyof T>(obj: T, key: K): T[K] => {
  return obj[key];
};

const person: Person = { name: "hiraoku", age: 30, address: "123 Main St" };

const name = getProperty(person, "name"); // "hiraoku"
const age = getProperty(person, "age"); // 30

このgetProperty関数は、keyofを使ってオブジェクトのキーを型として受け取り、そのキーに対応する値の型を返すようにしています。

keyofのユースケース

型の安全性を確保する

keyofを使うことで、オブジェクトのキーに基づいた安全な型チェックが可能になります。たとえば、無効なキーを指定するとコンパイルエラーになります。

Mapped Typesの作成

keyofはMapped Typesを作成する際にも役立ちます。以下は、オブジェクトの全キーに対応するプロパティをオプションにする例です。Mapped Typesについてはあとで詳しく説明します。

type PartialPerson = {
  [K in keyof Person]?: Person[K];
};

typeof

typeofは、変数や式の型を取得するために使われます。

実行時の型チェック

JavaScriptでは、typeofを使用して変数の実行時の型を確認できます。これはTypeScriptでも同様に動作します。

const value = "Hello, world!";
console.log(typeof value); // "string"

型定義での使用(TypeScript)

TypeScriptでは、typeofを使って既存の値や変数の型を取得し、新しい型定義を行うことができます。これにより、特定の値の型を再利用したり、他の型と組み合わせたりすることができます。

const person = {
  name: "hiraoku",
  age: 20,
};

type PersonType = typeof person;

この例では、PersonTypeは{ name: string; age: number }という型になります。typeofを使って、personオブジェクトの型を取得しています。

また関数の型も取得できます。

const greet = (name: string): string => {
  return `Hello, ${name}!`;
};

type GreetType = typeof greet;
// GreetType は (name: string) => string という型になる

前述のkeyofと併用すると、オブジェクトの変数からプロパティ名のユニオン型を作成することができます。

const person = {
 name: 'Hiraoku',
 age: 18,
 address: 'New York'
};

type PersonKeyType = keyof typeof personInfo;
// type PersonKeyType = "name" | "age" | "address"

typeofのユースケース

値から型を作成する

typeofを使って既存の変数から型を生成できるため、同じ型定義を繰り返す必要がなくなります。

型安全な依存関係

例えば、オブジェクトや関数の型が変更されたときに、その型を使用している他の部分も自動的に更新されるため、型の整合性が保たれます。

オブジェクトリテラルの型

オブジェクトリテラルの型定義を再利用したい場合、typeofを使うことで、そのオブジェクトに基づいた型を取得できます。

インデックスアクセス型(Indexed Access Types)

インデックスアクセス型(Indexed Access Types)は、オブジェクトや配列の特定のプロパティや要素の型を取得するための機能です。オブジェクトのプロパティ名や配列のインデックスを使って、その型を参照することができます。これにより、既存の型定義から部分的に型を抽出したり再利用したりすることが可能です。

基本

インデックスアクセス型を使うと、オブジェクトの特定のプロパティの型を取得できます。T[K]の形式で使用します。ここで、Tはオブジェクトの型で、Kはそのオブジェクトのキー(プロパティ名)です。

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

type NameType = Person['name']; // string
type AgeType = Person['age']; // number

この例では、NameTypeはstring型になり、AgeTypeはnumber型になります。

以下のようにすると、複数のプロパティにもアクセスでき、ユニオン型を作成することもできます。

type PersonDetails = Person['name' | 'age']; // string | number

余談ですが、直接的に変数をキーとして使用してインデックスアクセス型を定義することはできません。以下のコードではエラーが発生します。

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

const nameKey = 'name';
type NameType = Person[nameKey];
// エラー: Type 'nameKey' cannot be used as an index type.

インデックスアクセス型に使用するキーは、文字列リテラル型、ユニオン型、またはkeyof 演算子の結果である必要があります。通常の変数をそのままインデックスとして使うことはできません。

インデックスアクセスでは変数が使えます。混同しないように注意してください。

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

const person: Person = {
  name: 'John',
  age: 30,
};

const key: keyof Person = 'name'; // 変数 key に 'name' を設定
const value = person[key]; // 変数 key を使ってインデックスアクセス

console.log(value); // "John"

配列での使用

配列やタプルに対してもインデックスアクセス型を使うことができます。

type StringArray = string[];

type StringType = StringArray[number]; // string

ここでは、StringTypeはstring型になります。StringArray[number]は、配列StringArrayのすべてのインデックスに対応する型を指します。
T[K]の形式のKにnumberを使うことを覚えておいてください。

ネストされたオブジェクトでの使用

ネストされたオブジェクトのプロパティに対してもインデックスアクセス型を使用できます。

type Company = {
  name: string;
  employees: {
    name: string;
    role: string;
  }[];
};

type EmployeeNameType = Company['employees'][number]['name']; // string

この例では、EmployeeNameTypeはstring型になります。ここで、Company['employees'][number]['name']は、employees配列内の各オブジェクトのnameプロパティの型を取得しています。

インデックスシグネチャとの併用

インデックスシグネチャを持つオブジェクト型でもインデックスアクセス型は有効です。

type StringKeyDictionary = {
  [key: string]: boolean;
};

type StringKeyDictionaryType = StringKeyDictionary[string]; // boolean

type NumberKeyDictionary = {
  [key: number]: boolean;
};

type NumberKeyDictionaryType = NumberKeyDictionary[number]; // boolean

この例では、Dictionary[string]、Dictionary[number]はboolean型を返します。

条件付き型(Conditional Types)

条件付き型は、型に基づいて異なる型を選択する強力な機能です。まるで三項演算子(condition ? trueExpression : falseExpression)のように、条件に応じて異なる型を選択できます。条件付き型は、複雑な型を柔軟に扱うために非常に有用です。

基本

基本的な構文は次のとおりです。

T extends U ? X : Y

T extends U: TがUに代入可能かどうかをチェックします。
X: 条件がtrueの場合に返される型。
Y: 条件がfalseの場合に返される型。

基本的な使い方は次のとおりです。

type IsString<T> = T extends string ? "string" : "not string";

type A = IsString<string>; // "string"
type B = IsString<number>; // "not string"

ここで、IsStringはジェネリック型です。型Tがstring型に代入可能であれば、"string"を返し、そうでなければ"not string"を返します。

T extends U ? true : falseのように条件分岐を書くことで、TがUに含まれる場合はtrue、含まれない場合はfalseとなる型を生成できるってことですね。

また条件付き型は、never型と組み合わせることで、型の絞り込みを行うことができます。

type Exclude<T, U> = T extends U ? never : T;

type T1 = Exclude<"a" | "b" | "c", "a">; // "b" | "c"

条件付き型とnever型を組み合わせることで、ユニオン型から特定の型を除外したり、必要な要素だけを絞り込んだりすることができます。これにより、より柔軟で型安全なコードを記述できるようになります。

ユニオン型と条件付き型

ユニオン型と条件付き型を組み合わせると、それぞれのユニオン要素に対して条件が評価され、結果がユニオン型として返されます。

type ToArray<T> = T extends any ? T[] : never;

type StrArrOrNumArr = ToArray<string | number>; // (string[] | number[])

この場合、ToArray<string | number>はstring[] | number[]というユニオン型を返します。Tがユニオン型である場合、それぞれの要素に対して条件が個別に適用されます。

条件付き型内での推論

inferを使うと、条件付き型の中で型推論を行うことができます。inferを使用すると、特定の型から一部の情報を抽出することが可能です。このinferの使い方は覚えておいてくださいね。

type GetReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

type ReturnType1 = GetReturnType<() => string>; // string
type ReturnType2 = GetReturnType<(x: number) => boolean>; // boolean

ここでは、GetReturnTypeは関数型Tからその戻り値の型を抽出します。infer Rは、Rが関数の戻り値の型であることを示します。

type First<T> = T extends [infer U, ...any[]] ? U : never;

type FirstElement1 = First<[string, number, boolean]>; // string
type FirstElement2 = First<[42, true, "hello"]>; // 42
type FirstElement3 = First<[]>; // never
type FirstElement4 = First<number>; // never

Firstはタプル型から最初の要素の型を抽出します。

条件付き型の分配特性

条件付き型はユニオン型に対して分配される特性を持っています。つまり、ユニオン型に対して条件付き型を適用すると、各要素に対して条件が個別に評価されます。

type FilterString<T> = T extends string ? T : never;

type StringsOnly = FilterString<string | number | boolean>; // string

この例では、FilterString<string | number | boolean>はstring型だけを返し、他の型はneverになります。

条件付き型の例外

条件付き型の分配特性は、Tがneverの場合は分配されません。この点を理解しておくと、意図しない型の結果を避けることができます。

Mapped Types

Mapped Typesは、既存の型のプロパティを基に新しい型を生成するための機能です。Mapped Typesは、ある型のすべてのプロパティを別の型に変換する場合に非常に便利です。たとえば、オブジェクト型のプロパティをすべてオプションにしたり、すべてのプロパティを読み取り専用にしたりすることができます。

基本

type MappedType = {
  [Key in UnionType]: Type;
};

Key in UnionType: UnionTypeは、通常、keyofを使って既存の型のキーを表すユニオン型です。Keyは、そのユニオン型の各要素を1つずつ取り出して処理します。

Type: 新しく生成される型の各プロパティの型を指定します。

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

type PartialPerson = {
  [K in keyof Person]?: Person[K];
};

// PartialPerson の型
{
  name?: string;
  age?: number;
}

この例では、PartialPersonはPerson型のすべてのプロパティをオプション(?)にした型です。

keyofとの組み合わせ

keyofを使って、既存の型からプロパティのキーを取得し、それを利用して新しい型を生成することができます。

次の例はマッピング修飾子のreadonlyを使って、プロパティを読み取り専用にしています。

type Readonly<T> = {
  readonly [P in keyof T]: T[P];
};

マッピング修飾子は、他にも?もあり、また接頭辞に+や-をつけることによって、追加、削除することができます。

type Mutable<T> = {
 -readonly [P in keyof T]: T[P];
};
type Partial<T> = {
  [P in keyof T]?: T[P];
};

type Required<T> = {
  [P in keyof T]-?: T[P];
};

[P in keyof T]を使うことで、Tのすべてのプロパティをオブジェクトのキーとして列挙できます。

ちょっとだけ応用編 テンプレートリテラル型

Mapped Typesのところまでマスターすれば、type-challengesのEasyレベルまでは解ける状態となりました。お疲れ様でした。でもここでせっかくなのでちょっとだけ応用編、テンプレートリテラル型についても少しだけ触れておきたいと思います。

テンプレートリテラル型は、文字列リテラル型と組み合わせて、文字列のパターンを型として表現できる強力な機能です。テンプレートリテラル型を使うことで、文字列の構造を厳密に指定したり、動的に生成された文字列に対して型安全な操作を行ったりできます。

基本

通常のJavaScriptのテンプレートリテラルの構文を利用しつつ、型レベルで定義します。次のように、型の中で ${} を使用して他の型を参照することができます。

type Greeting = `Hello, ${string}`;

この例では、Greeting 型は Hello, で始まり、その後に任意の文字列が続く型になります。たとえば、"Hello, World" や "Hello, TypeScript" などはこの型に適合しますが、"Hi, World" や "HelloWorld" は適合しません。

応用

テンプレートリテラル型は、Union型や、既存のリテラル型と組み合わせて強力な型を定義できます。

type Action = 'create' | 'update' | 'delete';
type ActionMessage = `You can ${Action} an item.`;

const message: ActionMessage = "You can create an item."; // OK

ActionMessage 型は You can で始まり、次に Action のいずれかが続く文字列型です。

type Prefix = 'get' | 'set';
type Property = 'Name' | 'Age';
type Method = `${Prefix}${Property}`;

const method1: Method = 'getName'; // OK
const method2: Method = 'setAge';  // OK

Method 型は、getName, getAge, setName, setAge という4つの文字列リテラル型になります。

制約をもたせる方法

テンプレートリテラル型を使って、あるパターンに合致する文字列のみを許可する型を定義できます。

type ID = `${string}-${number}`;

const validID: ID = "abc-123"; // OK
const invalidID: ID = "abc-xyz"; // エラー

この例では、ID 型は - で区切られた文字列と数字から成る文字列のみが適合します。

まとめ

それでは本当にすらすら型生成ができるのか、type-challengesをやってみてください。
ここまで読んでいただきありがとうございました🙏🏻

https://github.com/type-challenges/type-challenges?tab=readme-ov-file

参考資料

https://www.typescriptlang.org/docs/handbook/2/types-from-types.html

Discussion