白猫のメモ帳

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

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が具体的にどんな使い方ができるのか見ていきます。