白猫のメモ帳

C#とかJavaとかJavaScriptとかHTMLとか機械学習とか。

TypeScriptの型を復習する その4

こんばんは。
雑多に本を買いすぎて積み本が増えてきました。

今回はConditional Typesを見ていきます。名前の通り条件分岐ができる型です。
なんでそんなことがしたいのかとか、どういうときに役に立つのかという話を先にしたいのですが、そもそも文法を知らないと話が進まないので、先に見ていきましょう。

条件型(Conditional Types)

型を要求する場面でextendsキーワードを使って三項演算子のように条件分岐を行います。
ちなみにこのextendsはジェネリック型の制約で使うextendsとは別物です。

test('基本形', () => {
    type IsString<T> = T extends string ? true : false;
    type A = IsString<string>;
    type B = IsString<number>;
    expectTypeOf<A>().toEqualTypeOf<true>();
    expectTypeOf<B>().toEqualTypeOf<false>();
});

ここで取れるtrueとかfalseは値ではなく型なので、リテラルなのがなかなかややこしいですね。

分配(Distributive Conditional Types)

条件型の特徴的な挙動として、分配というものがあります。
これは条件型がUnion型に対して適用された場合に、Union型の各メンバーに対して個別に条件型が適用されるというものです。

test('分配', () => {
    type ToStringIfNumber<T> = T extends number ? string : T;
    type A = ToStringIfNumber<number | boolean>;
    expectTypeOf<A>().toEqualTypeOf<string | boolean>();
});

この例では数値型なら文字列型にするという「ToStringIfNumber」という条件型が定義されています。
この時に「number型もしくはboolean型」に対して「number型ではないので変換しない」という挙動よりも、「number型ならstring型に、boolean型なら変換しない」と分解されてそれぞれに条件型が適用され、その結果が再びUnion型として結合されたほうが便利というのは納得感がありますね。

ちなみに明示的に分配したくない時には大括弧を使います。
ここでの[]は特殊な文法ではなく要素が一つだけのタプル型を作るための構文です。
分配はnaked型に対してだけ起こるので、タプル型にすることでUnion型が分解されなくなります。

test('分配を防ぐ', () => {
    type ToStringIfNumber<T> = [T] extends [number] ? string : T;
    type A = ToStringIfNumber<number | boolean>;
    expectTypeOf<A>().toEqualTypeOf<number | boolean>();
});

inferキーワード

inferは条件型の中でのみ使える特殊なキーワードで、型を推論するために使います。使う場所はextendsキーワードの右辺です。
ニュアンスとしては型のパターンマッチングみたいな感じでしょうか。
ちなみにextendsキーワードの左辺は引数として渡されるのでinferがなくても参照できます。推測しなくてもそのものずばりですしね。

test('infer', () => {
    type GetArrayElementType<T> = T extends Array<infer U> ? U : T;
    type A = GetArrayElementType<string[]>;
    type B = GetArrayElementType<number>;
    expectTypeOf<A>().toEqualTypeOf<string>();
    expectTypeOf<B>().toEqualTypeOf<number>();
});

で、いつなんで使うの?

はい、というわけで条件型、分配、inferキーワードをささっと確認しました。
ここで改めてなんでそんなことがしたいのか、どういうときに役に立つのかを考えてみましょう。

ここまでの例では型だけを変換していてなかなかピンとこないですが、関数と組み合わせると入力の型に応じて返す型を変えるみたいなことができます。
引数の型を使って戻り値の型を変更するというのはジェネリック型の典型的な使い方ですが、その拡張ができるわけです。

test('関数の戻り値の型を変える', () => {
    type ToStringIfNumber<T> = T extends number ? string : T;
    function toStringIfNumber<T>(value: T): ToStringIfNumber<T> {
        if (typeof value === 'number') {
            return value.toString() as ToStringIfNumber<T>;
        } else {
            return value as ToStringIfNumber<T>;
        }
    }
    expect(toStringIfNumber(123)).toBe("123");
    expect(toStringIfNumber(true)).toBe(true);
});

そして条件型はTypeScript標準ライブラリの中でも多用されています。
前回の記事で確認したUtility Typesも条件型を使って実装されているものがいくつかあります。

Excludeはユニオン型から指定した型を除外した型を生成しますが、これはまさに条件型と分配の組み合わせです。
このようにユニオン型の中から「条件に合うものだけ」を取り出したいという場面でも使えます。

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

ReturnTypeは関数の戻り値の型を取得しますが、これは条件型とinferキーワードの組み合わせです。
このように、ある型パターンから「一部の型」を取り出したいというニーズでも便利ですね。

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

そんなわけで、型から特定の情報を取り出したり、型を変換したりするような場面がTypeScriptではよくあります。
そのような場合に「条件型をうまく使うとその表現力が上がって便利だよね」というのが、いつなんで使うのに対する答えになります。

これでTypeScriptの型を復習するシリーズは終了の予定です。
もしかしたらReactとかの型を見て何がどうなってるのか見てみるとかの番外編を書くかもしれませんが、いったんはここまでということで。

TypeScriptの型を復習する その3

こんばんは。
ブラックフライデーで何を買うか決めましたか?

今回はUtility Typesを一気に確認していきます。
前回の記事で解説した通り、Utility Typesは型から別の型に変換するための型です。型レベルの関数みたいなイメージですかね。

今回紹介する型はすべて「lib.es5.d.ts」としてTypeScriptの標準ライブラリに定義されています。
できるだけそのまま「lib.es5.d.ts」のそのままの順番で紹介しますが、一部わかりやすさのために順番は入れ替えています。

Partial

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

Partialは与えられた型のすべてのプロパティをオプショナルにした型を生成します。

test('Partial型', () => {
    type User = {
        name: string;
        age: number;
    };
    type PartialUser = Partial<User>;
    const user: PartialUser = { name: "Alice" };
    expect(user).toEqual({ name: "Alice" });
});

Required

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

RequiredはPartialの逆で、与えられた型のすべてのプロパティを必須にした型を生成します。

test('Required型', () => {
    type User = {
        name?: string;
        age?: number;
    };
    type RequiredUser = Required<User>;
    const user: RequiredUser = { name: "Bob", age: 25 };
    expect(user).toEqual({ name: "Bob", age: 25 });
});

Readonly

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

Readonlyは与えられた型のすべてのプロパティを読み取り専用にした型を生成します。

test('Readonly型', () => {
    type User = {
        name: string;
        age: number;
    };
    type ReadonlyUser = Readonly<User>;
    const user: ReadonlyUser = { name: "Charlie", age: 30 };
    // user.name = "Dave"; // エラー: 読み取り専用プロパティ 'name' に代入することはできません。
    expect(user).toEqual({ name: "Charlie", age: 30 });
});

Record

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

Recordはユニオン型の各要素をキーとし、指定した型を値とするオブジェクト型を生成します。

test('Record型', () => {
    type UserRoles = 'admin' | 'user';
    type RolePermissions = Record<UserRoles, string[]>;
    const permissions: RolePermissions = {
        admin: ['read', 'write', 'delete'],
        user: ['read', 'write']
    };
    expect(permissions).toEqual({
        admin: ['read', 'write', 'delete'],
        user: ['read', 'write']
    });
});

型から生成する場合にはkeyof演算子と組み合わせて使うことになります。

test('Record型(型から)', () => {
    type User = {
        name: string;
        age: number;
    };
    type UserRecord = Record<keyof User, string>;
    const user: UserRecord = {
        name: "Frank",
        age: "40"
    };
    expect(user).toEqual({ name: "Frank", age: "40" });
});

オブジェクトから生成する場合にはkeyof演算子とtypeof演算子の両方と組み合わせて使うので若干呪文っぽさがでてきます。

test('Record型(オブジェクトから)', () => {
    const user = {
        name: "Grace",
        age: 35
    };
    type UserRecord = Record<keyof typeof user, string | number>;
    const userRecord: UserRecord = {
        name: "Grace",
        age: 35
    };
    expect(userRecord).toEqual({ name: "Grace", age: 35 });
});

ちなみにstringなどのリテラルでない型を指定することもできます。

test('Record型(リテラルでない)', () => {
    type UserRecord = Record<string, string>;
    const user: UserRecord = {
        name: "Heidi",
        age: "28"
    };
    expect(user).toEqual({ name: "Heidi", age: "28" });
});

つまりどういうことかというと、Record型はインデックス型のエイリアスシンタックスシュガー?みたいなものなので、Record<string, string>は{ [key: string]: string }と同じ意味になるということですね。

Exclude

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

Excludeはユニオン型から指定した型を除外した型を生成します。

test('Exclude型', () => {
    type UserRoles = 'admin' | 'user';
    type NonAdminRoles = Exclude<UserRoles, 'admin'>;
    const role: NonAdminRoles = 'user';
    expect(role).toBe('user');
});

Extract

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

Extractはユニオン型から指定した型だけを抽出した型を生成します。

test('Extract型', () => {
    type UserRoles = 'admin' | 'user';
    type AdminRoles = Extract<UserRoles, 'admin'>;
    const role: AdminRoles = 'admin';
    expect(role).toBe('admin');
});

Omit

type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;

Omitは与えられた型から指定したプロパティを除外した型を生成します。Excludeはユニオン型に対して使いましたが、Omitはオブジェクト型に対して使います。

test('Omit型', () => {
    type User = {
        name: string;
        age: number;
        email: string;
    };
    type UserWithoutEmail = Omit<User, 'email'>;
    const user: UserWithoutEmail = { name: "Frank", age: 40 };
    expect(user).toEqual({ name: "Frank", age: 40 });
});

Pick

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

Pickは与えられた型から指定したプロパティだけを抽出した型を生成します。こちらもExtractはユニオン型に対して使いましたが、Pickはオブジェクト型に対して使います。

test('Pick型', () => {
    type User = {
        name: string;
        age: number;
        email: string;
    };
    type UserNameAndEmail = Pick<User, 'name' | 'email'>;
    const user: UserNameAndEmail = { name: "Eve", email: "eve@example.com" };
    expect(user).toEqual({ name: "Eve", email: "eve@example.com" });
});

NonNullable

type NonNullable<T> = T & {};

NonNullableは型からnullとundefinedを除外した型を生成します。
Requiredはプロパティレベルでオプショナルを除外しましたが、NonNullableは型レベルでnullとundefinedを除外するのがちょっとややこしいですね。

test('NonNullable型', () => {
    type User = {
        name: string | null;
        age: number | undefined;
    };
    type NonNullableUser = {
        name: NonNullable<User['name']>;
        age: NonNullable<User['age']>;
    };
    type User2 = NonNullable<User>;    // User自体はnullableではないので特に意味はない
    const user: NonNullableUser = { name: "Grace", age: 35 };
    expect(user).toEqual({ name: "Grace", age: 35 });
});

Parameters

type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;

Parametersは関数型の引数の型をタプル型として取得します。

test('Parameters型', () => {
    type GreetFunction = (name: string, age: number) => string;
    type GreetParameters = Parameters<GreetFunction>;
    const params: GreetParameters = ["Heidi", 28];
    expect(params).toEqual(["Heidi", 28]);
});

ReturnType

type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;

ReturnTypeは関数型の戻り値の型を取得します。

test('ReturnType型', () => {
    type GreetFunction = (name: string) => string;
    type GreetReturnType = ReturnType<GreetFunction>;
    const greeting: GreetReturnType = "Hello, Ivan!";
    expect(greeting).toBe("Hello, Ivan!");
});

ConstructorParameters

type ConstructorParameters<T extends abstract new (...args: any) => any> = T extends abstract new (...args: infer P) => any ? P : never;

ConstructorParametersはクラスのコンストラクタの引数の型をタプル型として取得します。

test('ConstructorParameters型', () => {
    class Person {
        constructor(public name: string, public age: number) {}
    }
    type PersonConstructorParameters = ConstructorParameters<typeof Person>;
    const params: PersonConstructorParameters = ["Judy", 32];
    expect(params).toEqual(["Judy", 32]);
});

InstanceType

type InstanceType<T extends abstract new (...args: any) => any> = T extends abstract new (...args: any) => infer R ? R : any;

InstanceTypeはコンストラクタ関数(=クラス)から、その「インスタンスの型」を取り出す型です。
何言ってるんだって感じですが、クラスは型であって値でもあるので、typeof型演算子を使うと値として扱われてコンストラクタ関数の型になるわけです。

test('InstanceType型', () => {
    class Animal {
        constructor(public species: string) {}
    }
    type AnimalConstructorType = typeof Animal; // 値として扱われてコンストラクタ関数の型になる
    type AnimalInstanceType = InstanceType<AnimalConstructorType>;
    const animal: AnimalInstanceType = new Animal("Dog");
    expect(animal.species).toBe("Dog");
});

で、じゃあどういうときに使うかというと、new T()みたいなことをするときに便利らしいです。
C#はwhere T : new()みたいな制約が使えるのでそんなに困ってなかったですが、そういえばJavaでも同じような悩みがあった気がします。

test('new T()的な', () => {
    function createInstance<C extends new (...args: any) => any>(C: C): InstanceType<C> {
      return new C();
    }
    class Dog {}
    class Cat {}
    const dog = createInstance(Dog);    // 型ではなくて値(コンストラクタ関数と解釈される)
    const cat = createInstance(Cat);    // 型ではなくて値(コンストラクタ関数と解釈される)
    expect(dog).toBeInstanceOf(Dog);
    expect(cat).toBeInstanceOf(Cat);
});

ちなみにですが、typeにはtypeof型演算子は使えません。インタフェースにも使えません。
クラスにだけ使えるのでなんか混乱しますね。

const hoge = {};
type HogeType = typeof hoge;
class HogeClass {}
type HogeClassType = typeof HogeClass;
interface HogeInterface {}
// type HogeInterfaceType = typeof HogeInterface; // エラー: 'HogeInterface' は型のみを参照しますが、ここで値として使用されています。
// type HogeTypeType = typeof HogeType; // エラー: 'HogeType' は型のみを参照しますが、ここで値として使用されています。

Parameters、ReturnType、ConstructorParameters、InstanceTypeはちょっとリフレクションっぽいメタプログラミングみを感じます。

Uppercase / Lowercase / Capitalize / Uncapitalize / NoInfer

type Uppercase<S extends string> = intrinsic;
type Lowercase<S extends string> = intrinsic;
type Capitalize<S extends string> = intrinsic;
type Uncapitalize<S extends string> = intrinsic;
type NoInfer<T> = intrinsic;

このあたりは名前の通りなんですが(NoInferは置いておいて)、実装を見ると全部「intrinsic」となっていて、TypeScriptのコンパイラ内部で特別扱いされている型です。
「intrinsic」は「組み込みの」とかそんなニュアンスになるかなと思います。TypeScriptってそんなこともできるんですね。

はい、結構たくさんあるのでボリューム多くなってしまいましたね。
次回はConditional Typesです。

TypeScriptの型を復習する その2

こんばんは。
布団から出たくないです。

今回はインデックス型、インデックスアクセス型、Mapped Typesについて見ていきます。

インデックス型(index signature)

インデックス型はオブジェクトのプロパティ名とその型を動的に指定する方法です。
イメージとしては「JS のオブジェクトを連想配列(dictionary)として使うときに、その “キーと値の型” を TypeScript で明示したもの」みたいな感じです。

type Dictionary = {
    [key: string]: number;
};

素のオブジェクトは定義していないプロパティには当然アクセスできません。
any型を使えばアクセスできますが、型安全ではなくなるのでTypeScriptでとるべき手段ではないですね。

test('any型', () => {
    const obj1 = {};
    // obj1['a'] = 1;   // エラー: プロパティ 'a' は型 '{}' に存在しません。

    const obj2 = {} as any;
    obj2['a'] = 1;
    expect(obj2['a']).toBe(1);
});

こういう時にインデックス型を使うと便利です。
宣言の仕方とか使い方的にはC#のインデクサに似ていますが、あちらはシンタックスシュガーなので仕組みとしてはちょっと違いますね。
インデックスアクセスでもプロパティアクセスでも両方使えます。

test('インデックス型', () => {
    const obj3: { [key: string]: number } = {};
    obj3['a'] = 1;
    obj3.b = 2;
    expect(obj3.a).toBe(1);
    expect(obj3['b']).toBe(2);
});

通常のプロパティとインデックス型を併用することもできますが、既存のプロパティの型を包含する必要があります。

test('インデックス型と既存プロパティの併用', () => {
    type User = {
        name: string;
        [key: string]: number | string; // 既存プロパティの型を包含する必要がある
    };
    const user: User = {
        name: "Alice",
        age: 30,
    };
    expect(user.name).toBe("Alice");
    expect(user.age).toBe(30);
});

インデックスアクセス型(indexed access types)

とても似た名前でややこしいのですが、インデックスアクセス型は型システムの機能で、ある型の特定のプロパティの型を取得する方法です。
こんな感じの型を用意します。

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

なんだか難しい感じがしますが、要は型に対してプロパティアクセスをするイメージなので実は割とシンプルです。

test('インデックスアクセス型', () => {
    type UserNameType = User['name'];
    expectTypeOf<UserNameType>().toEqualTypeOf<string>();
});

ただ、プロパティ名にUnion型を指定すると、そのプロパティの型のUnion型が取得できたりするあたりが少しトリッキーです。

test('Union型', () => {
    type UserPropertyTypes = User['name' | 'age'];
    expectTypeOf<UserPropertyTypes>().toEqualTypeOf<string | number>();
});

そしてプロパティのUnion型といえば前回紹介した「keyof演算子」ですね。

test('keyof', () => {
    type UserPropertyTypes = User[keyof User];
    expectTypeOf<UserPropertyTypes>().toEqualTypeOf<string | number>();
});

配列の場合には、インデックスアクセス型に「number」を指定すると要素の型が取得できるのもちょっと不思議な感じがします。

test('配列のインデックスアクセス型', () => {
    type StringArray = string[];
    type ElementType = StringArray[number];
    expectTypeOf<ElementType>().toEqualTypeOf<string>();
});

インデックス型が「型を作るルール」、インデックスアクセス型が「型から取り出すルール」みたいな。

Mapped Types

Mapped Typesは文法的にはインデックス型に制約を付ける感じなのですが、名前的にもこのニュアンスだとちょっとしっくりきません。

test('Union型から', () => {
    type Keys = 'a' | 'b' | 'c';
    type MappedType = {
        [K in Keys]: number;
    };
    const obj: MappedType = {
        a: 1,
        b: 2,
        c: 3,
    };
    expect(obj.a).toBe(1);
    expect(obj.b).toBe(2);
    expect(obj.c).toBe(3);
});

説明が難しいところなんですが、どちらかというとインデックス型の仕組みを使って型の構造をキー単位で変換する方法とかがわかりやすいです。
型の写像というかなんというか、辞書のmapというよりは、関数のmapのようなイメージですかね。
この場合だと何も変換していないのであまり意味がないですが、インデックス型とインデックスアクセス型と「keyof演算子」を組み合わせてこんな感じで書けます。

test('既存の型から', () => {
    type User = {
        name: string;
        age: number;
    };
    type NewUser = {
        [K in keyof User]: User[K];
    };
    const user: NewUser = {
        name: "Bob",
        age: 25,
    };
    expect(user.name).toBe("Bob");
    expect(user.age).toBe(25);
});

意味のある事をするなら、例えばすべてのプロパティを読み取り専用にできます。
ReadOnlyはUtility Typesに組み込まれていますが、自分で実装するならこんな感じです。

test('読み取り専用', () => {
    type User = {
        name: string;
        age: number;
    };
    type ReadonlyUser = {
        readonly [K in keyof User]: User[K];
    };
    const user: ReadonlyUser = {
        name: "Alice",
        age: 30,
    };
    // user.age = 31; // エラー: 読み取り専用プロパティであるため、'age' に代入することはできません。
});

インデックス型では既存のプロパティと併用できましたが、Mapped Typesではちょっと工夫が必要です。

test('Mapped Typesと既存プロパティの併用', () => {
    type User = {
        name: string;
        age: number;
    };
    type AdminUser = {
        [K in keyof User]: User[K];
        // role: string; // エラー: keyofで包含はできないので併用できない
    } & {
        role: string;   // Intersection型で表現
    };
    const adminUser: AdminUser = {
        name: "Charlie",
        age: 28,
        role: "admin",
    };
    expect(adminUser.name).toBe("Charlie");
    expect(adminUser.age).toBe(28);
    expect(adminUser.role).toBe("admin");
});

次回は各種Utility Typesを確認して、Mapped Typesが具体的にどんな使い方ができるのか見ていきます。