11
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Mapped Types とか Union Types とかって何だっけ

Posted at

TypeScript には型を操作するためのさまざまな方法が提供されています。それぞれの操作の使い方を知らないと、型宣言は単なる記号の羅列で難解なパズルのように思えてきます。

例としてテストダブルのパッケージ Sinon.JS の型定義ファイルから Stub の型宣言を見てみましょう。
https://github.com/DefinitelyTyped/DefinitelyTyped/blob/6ed45a5658df53f5665831e2dbff54ef6c33afc5/types/sinon/index.d.ts

interface SinonStubStatic {
  <TArgs extends any[] = any[], R = any>(): SinonStub<TArgs, R>;
  <T>(obj: T): SinonStubbedInstance<T>;
  <T, K extends keyof T>(obj: T, method: K): T[K] extends (...args: infer TArgs) => infer TReturnValue
    ? SinonStub<TArgs, TReturnValue>
    : SinonStub;
}

初見殺しですね。

かくいう私もソースコードを書く上で「どうやって型を書くんだっけ」「あの型の書き方は何ていう名前だっけ」と日常的にプチ混乱します。そこで、基本的な型の操作と、それらを組み合わせた表現をまとめてみたいと思います。

この記事について

  • TypeScript 4.3 playground にて検証したサンプルコードを記載しています
  • プリミティブなど TypeScript の基本的な型 について理解している事を前提とします
  • Mapped Types ってえーと何だっけ?くらいのレベル感の方を対象読者としています

基本的なの型の操作

Generics

Generics | 公式ドキュメント

型をパラメータとして扱う事ができる仕組みです。例えば下記のように、オブジェクトの一部を異なる型に変えて再利用する事ができます。

// id の型を Generics で受け取る
type Item<T> = {
  id: T;
  name: string;
  price: number;
}

// id を string で扱う型
const books: Item<string>[] = [
  { id: "1111111111000", name: "ザリガニ図鑑", price: 1200 },
  { id: "1111111111001", name: "邪馬台国の謎", price: 2500 },
];

// id を number で扱う型
const foods: Item<number>[] = [
  { id: 1111, name: "極上バナナヨーグルト", price: 280 },
  { id: 1112, name: "レトルト牛丼3個入", price: 480 },
];

型指定のプレースホルダには、任意のテキストを指定できます。

// T がよく使われる
type Item<T> = {
  id: T;
}

// 任意のテキストも利用可能
type Fetch<Response>: (id: number) => Response;

TypeScript の推論が効く範囲では、ジェネリクスの型指定は省略する事ができます。

function add<T>(list: T[], item: T): T[] {
  return list.concat(item);
}

add([1, 2], 3);
// > [1, 2, 3]

add(["a", "b"], "c");
// > ["a", "b", "c"]

add([1, 2], "a");
// > @error Argument of type 'string' is not assignable to parameter of type 'number'.
// T は number と推論されるため、string の "a" は指定できない

extends を使うと派生元の型を限定する事ができます。

// id を持つオブジェクトを受け付ける
function increment<T extends { id: number }>(param: T): T {
  return {
    ...param,
    id: param.id + 1,
  };
}

increment({ id: 1 });
// > { id: 2 }

increment({ id: 100, name: "aa" });
// > { id: 101, name: "aa" }

increment({ foo: "bar" });
// @error Argument of type '{ foo: string; }' is not assignable to parameter of type '{ id: number; }'.
// { id: number } にマッチしない

= を使うと Generics で受け取る型にデフォルトを持たせる事ができます。

function greet<T = string>(name: T): string {
  return `Hello ${name}`;
}

greet("me");
// > "Hello me"

greet(12345);
// > "Hello 12345"

Union Types

Unions and Intersection Types | 公式ドキュメント

型の OR 条件を表現する事ができます。

// size に指定できるテキストを固定
type Pizza = {
  name: string;
  size: "S" | "M" | "L";
  count: number;
}

type SideMenu = {
  name: string;
  count: number;
};

// Pizza または SideMenu のいずれかを受け付ける型を宣言
type Order = (Pizza | SideMenu);

const orders: Order[] = [
  { name: "チーズてんこ盛りピザ", size: "L", count: 1 },
  { name: "フライドオニオン", count: 1 },
];

Union Types を使うと選択肢を限定できるため、誤った値の代入を避ける事ができるようになります。

type pizza: Pizza = {
  name: "特大ピザ",
  count: 1,

  size: "XL",
  // @error Type '"XL"' is not assignable to type 'Size'.
  // 選択肢として存在しないサイズを指定した
};

const orders: Order[] = [
  { name: "クリスピータイプ生地" },
  // @error Type '{ name: string; }' is not assignable to type 'Pizza | SideMenu'.
  // Pizza にも SideMenu にも一致しないためエラー
];

Mapped Types

Mapped Types | 公式ドキュメント

オブジェクトのキー・バリューを走査した型指定ができます。

type HttpRequest = {
  body: string;
  // 宣言時に特定できないキーを任意のテキストとして受け付ける
  header: {
    [key: string]: string;
  },
};

const response: HttpRequest = {
  body: "abc",
  header: {
    "Authorization": "secret",
    "Accept-Language": "ja",
  },
};

Indexed Access Types

Indexed Access Types | 公式ドキュメント

ブラケット [] による型の取り出しを意味します。

type Actions = {
  fetch: () => Promise<string>;
  post: () => Promise<void>;
};

type Fetch = Actions["fetch"];
// > () => Promise<void>

type Post = Actions["post"];
// > () => Promise<void>

type Put = Actions["put"];
// @error Property 'put' does not exist on type 'Actions'.
// 存在しないインデックスはエラーになる

Conditional Types

Conditional Types | 公式ドキュメント

型の条件分岐を作る事ができます。

// 送信テキストを持つ Message 型
interface Message {
  body?: string;
  text?: string[];
}

// Message 型の派生
interface Email extends Message {
  title: string;
  body: string;
  to: string;
}
interface Popup extends Message {
  text: string[];
  dismiss: boolean;
}

// メッセージ送信するテキストの型を取り出す
type MessageText<T extends Message> = 
  T extends { body: string } ? T["body"] : T["text"];

type EmailText = MessageText<Email>;
// > string

type PopupText = MessageText<Popup>;
// > string[]

infer キーワードを使うと、内包する型を取り出す事ができます。

interface User {
  id: number;
  name: string;
}

type Fetch = () => Promise<User>;
type Post = () => Promise<User["id"]>;

// Promise の内包する型を取り出す
type UserRequest<T> = T extends () => Promise<infer U> ? U : never;

type FetchResponse = UserRequest<Fetch>;
// > User

type PostResponse = UserRequest<Post>;
// > number

Template Literal Types

Template Literal Types | 公式ドキュメント

テンプレートとして使用するテキストを型として表現できます。

type Actions = {
  change: () => void;
  input: () => void;
  select: () => void;
};

type EventName = `${string & keyof Actions}`;

function addListener(event: EventName, listener: () => void) { ... }

addListener("change", () => {});
addListener("input", () => {});
addListener("select", () => {});

addListener("cchange", () => {});
// @error Argument of type '"cchange"' is not assignable to parameter of type '"change" | "input" | "select"'.
// テンプレートに一致しないテキスト

Intrinsic String Manipulation Types を使うと、型として使うテキストの先頭の大文字・小文字などを変化させる事ができます。

type EventName = `on${string & Capitalize<keyof Actions>}`;

addListener("onChange", () => {});
addListener("onInput", () => {});
addListener("onSelect", () => {});

addListener("change", () => {});
// @error Argument of type '"change"' is not assignable to parameter of type '"onChange" | "onInput" | "onSelect"'.
// テンプレートに一致しないテキスト

Utility Types

Utility Types | 公式ドキュメント

ビルトインの型変換ユーティリティに頻度の高い型が用意されています。その中からいくつか紹介します。

Partial

オブジェクトのプロパティを一括でオプショナルに変換する事ができます。

type User = {
  id: number;
  firstName: string;
  lastName: string;
  nickname: string;
  age: number;
  address: string;
};

type Profile = Partial<User>;
// {
//   id?: number;
//   firstName?: string;
//   lastName?: string;
//   nickname?: string;
//   age?: number;
//   address?: string;
// };

const profile = {
  nickname: "a",
};

Readonly

オブジェクトのプロパティを一括で読み取り専用に変換する事ができます。

type User = {
  firstName: string;
  lastName: string;
};

type PersistedUser = Readonly<User>;
// {
//   readonly firstName: string;
//   readonly lastName: string;
// }

const user: PersistedUser = {
  firstName: "a",
  lastName: "b",
};

user.firstName = "aaa";
// @error Cannot assign to 'firstName' because it is a read-only property.
// 読み取り専用のため変更不可

Record

キーとなるテキストと値の型を指定して、新しいオブジェクト型を生成する事ができます。

type PackageId = "core" | "sdk" | "utility"

type Package = {
  version: string;
  updatedAt: Date;
};

type Packages = Record<PackageId, Package>;
// {
//   core: { version: string; updatedAt: Date };
//   sdk: { version: string; updatedAt: Date };
//   utility: { version: string; updatedAt: Date };
// }

const packages: Packages = {
  core: { version: "v1.0", updatedAt: new Date("2021-01-19") },
  sdk: { version: "v0.31", updatedAt: new Date("2021-01-05") },
  utility: { version: "v5.2", updatedAt: new Date("2021-01-12") },

  // @error 'plugin' does not exist in type 'Record<PackageId, Package>'.
  // 誤ったキーや値は混入できない
  plugin: { version: "v20" },
};

ConstructorParameters

コンストラクタの引数をタプル型として取り出す事ができます。

class User {
  constructor(
    private id: number,
    private name: string,
  ) {}
}

type UserArgs = ConstructorParameters<typeof User>;
// > [id: number, name: string]

// コンストラクタと同じ引数を取る関数を宣言できる
function factory(args: UserArgs) {
  return new User(...args);
}

factory([1, "userA"]);

型って何が嬉しいのか

冒頭の Sinon.JS のコードを再掲します。ここまでざっと読んでいただいた方には、これらのコードは型の操作を組み合わせた型宣言である、という事が理解いただけるのではないかと思います。

interface SinonStubStatic {
  // Generics を使って対象オブジェクトをスタブに変換している
  <TArgs extends any[] = any[], R = any>(): SinonStub<TArgs, R>;

  // 対象オブジェクトをスタブのインスタンスに変換して返す
  <T>(obj: T): SinonStubbedInstance<T>;

  // 対象オブジェクトとメソッド名を引数に取る関数を宣言し、メソッドの戻り値の型を推論して返す
  <T, K extends keyof T>(obj: T, method: K): T[K] extends (...args: infer TArgs) => infer TReturnValue
    ? SinonStub<TArgs, TReturnValue>
    : SinonStub;
}

この型宣言はパラメータとして渡すテスト対象オブジェクトに応じて、開発者のための便利な IDE のサジェストを提供してくれるようになります。

Sinon.JS のサジェストの例

また、誤った呼び出しにはコンパイルエラーを発生させ、開発者にそれを気づかせてくれます。

Sinon.JS の誤った呼び出しの例

もし推論も何もないシンプルな interface であったなら、開発者は IDE のサジェストを頼る事ができず、スタブの対象オブジェクトやメソッドを自分で覚えていないといけない事になります。

interface StubStatic {
  (obj: any): StubbedInstance;
  returnValue(method: string, value: any): any;
}

const stub: StubStatic(obj);
stub.returnValue("method1", "abc");
// "method1" が存在する事を覚えていないといけない
// 戻り値が string である事を覚えていないといけない
// 元のオブジェクトのメソッド名や戻り値が変わっても、コンパイルエラーが出ないので修正箇所に気づきにくい

このように、型の操作を組み合わせる事で、柔軟な型の抽出と、安全な宣言を導く事ができるようになるのです。

型の操作を組み合わせた表現

Mapped Types や Union Types は型宣言のシンタックスのようなものです。単体で使う事もありますが、それらを組み合わせる事で型宣言の表現をさらに豊かなものにする事ができます。

オブジェクトの型を結合する

type BasicActions = {
  click: () => void;
  select: () => void;
};

type CustomActions = {
  popup: () => void;
  render: () => void;
};

type Actions = BasicActions & CustomActions;

const actions: Actions = {
  click: () => {},
  select: () => {},
  popup: () => {},
  render: () => {},
};

オブジェクトの型を拡張する

open-ended に準拠した interface は同名の宣言があると自動的にマージされます。

interface Actions {
  click: () => void;
  select: () => void;
};

// 既存の型を拡張する
interface Actions {
  popup: () => void;
  render: () => void;
};

const actions: Actions = {
  click: () => {},
  select: () => {},
  popup: () => {},
  render: () => {},
};

オブジェクトの型の一部を除外する

type Actions = {
  click: () => void;
  select: () => void;
  popup: () => void;
  render: () => void;
};

type CustomActions = Omit<Actions, "popup" | "render">;

const actions: CustomActions = {
  click: () => {},
  select: () => {},
}

値から型を得る

const config = {
  user: {
    name: "userA",
    email: "user-a@example.com",
  },
  activity: {
    actionOfThisMonth: 5,
    lastLoggedIn: new Date("2021-01-01"),
  },
};

type Activity = typeof config["activity"];
// {
//   actionOfThisMonth: number;
//   lastLoggedIn: Date;
// }

値からオブジェクトのキーを得る

const config = {
  user: {
    name: "userA",
    email: "user-a@example.com",
  },
  activity: {
    actionOfThisMonth: 5,
    lastLoggedIn: new Date("2021-01-01"),
  },
  updatedAt: new Date("2021-01-01"),
};

type ActivityKey = keyof typeof config["activity"];
// > "actionOfThisMonth" | "lastLoggedIn"

Union Types をオブジェクトのキーに利用する

type Keys = "name" | "nickname";

type User = {
  [K in Keys]: string;
};

const user1: User = { 
  name: "foo",
  nickname: "bar",
};

Union Types の一部をオブジェクトのキーに利用する

type Keys = "name" | "nickname";

type User = {
  [K in Keys]?: string;
};

// OK
const user1: User = { 
  name: "foo",
};

// OK
const user2: User = { 
  nickname: "foo",
};

オブジェクトの値の型を一括変換

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

type Flags<T> = {
  [K in keyof T]: boolean;
};

type X = Flags<User>;
// {
//   name: boolean,
//   age: boolean,
// }

オブジェクトのキー名を変換

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

type Fetches<T> = {
  [K in keyof T as `fetch${Capitalize<string & K>}`]: T[K];
};

type X = Fetches<User>;
// {
//   fetchName: string;
//   fetchAge: number;
// }

オブジェクトから任意の型に一致する値のみ抽出

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

type Filter<T, U> = {
  [K in keyof T]: T[K] extends U ? K : never;
}[keyof T];

type X = Pick<User, Filter<User, string>>;
// {
//   name: string;
//   address: string;
// }

オブジェクトから任意の型のキーを抽出

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

type Filter<T, U> = {
  [K in keyof T]: T[K] extends U ? K : never;
}[keyof T];

type X = Filter<User, string>;
// "name" | "address"

配列の内包する型を取り出す

export type Unzip<T> = T extends (infer U)[] ? U : never;

type Config = {
  users: {
    id: number;
    name: string;
  }[];
};

type User = Unzip<Config["users"]>;
// {
//   id: number;
//   name: string;
// }

ネストしたエラーの表現

type CustomError = { message: string };

type NestedError = {
  [key: string]:
    | CustomError
    | { [key: string]: CustomError }
    | CustomError
    | CustomError[];
};

const formError: NestedError = {
  username: { message: "氏名の間にスペースを入れてください" },
  address: { message: "住所を入力してください" },
  orders: [
    { message: "この商品は在庫切れです" },
    { message: "個数を入力してください" }
  ],
  form: [
    { message: "注文の受付期間を過ぎています" },
  ],
};

コンストラクタの束縛

// コンストラクタで id を取る User 型の宣言
interface User {
  id: number;
}
interface UserConstructor {
  new (id: number): User;
}

// User 型の派生
class TrialUser implements User {
  constructor(public id: number) {}
}
class PremiumUser implements User {
  constructor(public id: number) {}
}

// User 型を返すファクトリ関数
function factory(plan: string, id: number): User {
  const users: { [key:string]: UserConstructor } = {
    trial: TrialUser,
    premium: PremiumUser,
  };

  if (!users[plan]) {
    throw new Error(`Invalid plan ${plan}`);
  }

  return new users[plan](id);
}

おわりに

いかがでしたでしょうか。型に慣れないうちは目がチカチカするかもしれません。

私は普段コードを書きながら「誤ったパラメータに対してコンパイルエラーが得られるか?」を気にするようにしています。わざとタイポしたり、誤ったオブジェクトをパラメータに渡してみたりしながら、安全でないコードが安全なコードに変わるように型宣言を加えていきます。

最初の頃は全く型宣言が書けず、記号でググっても読みたいサンプルコードにヒットせず、めちゃくちゃ苦戦しました。そんな時に頭の中を整理しようと思って読んだ本があります。2019年出版ですが基本的な概念は変わっていないため、型とは何か?を学びたい人向けに是非おすすめしたい一冊です。

実践TypeScript マイナビ出版

複雑な型は未だうまいこと書けずにコメントで補足する事もありますが、毎日一歩ずつ、スマートな型宣言の書き方を身に着けていけたらと思っています。

11
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?