✍️

[TS TIP] 実用的な型定義を理解する 14選(後編)

に公開

最初に

前編では型レベル関数、Conditional Types、infer、satisfies など、
「型を操作する基礎」 を扱いました。

後編では 8~14 を扱います。

前編は以下です。
https://zenn.dev/ncdc/articles/022a1500718a71

では、始めます。

8. Template Literal Types で文字列パターンを型にする

TypeScript 4.1 以降、文字列型を 文字列の組み合わせパターンで定義できます。

API の URL を型で保証したい場合

type ApiVersion = "v1" | "v2";
type ApiPath = "users" | "posts";

type ApiUrl = `/api/${ApiVersion}/${ApiPath}`;

const a: ApiUrl = "/api/v1/users";   // OK
const b: ApiUrl = "/api/v3/abc";     // NG(型が保証してくれる)

実務で API を扱う時に便利。

9. 再帰型でネスト構造を表現する

TypeScript の型は 再帰することが可能です。

つまり、わざわざネスト構造を外出しの型定義で結合して表現しなくても、一つの型定義で良いということです。(この方が見やすいし直感的にわかりやすいです)

型を定義する

type Nested<T> = {
  key: T;
  description?: T;
  children?: Nested<T>[];
};

const node: Nested<string | number> = {
  key: 1,
  description: "01データです",
  children: [
    { key: "child1" },
    { key: "child2", description: "child2です", children: [{ key: "nestedChild1" }] }
  ]
};

再帰という名の通り、これはJSONのネスト構造を表現したいときなどに便利です。

10.Brand型で「すり替え防止」を実現する

TypeScript は本来 構造的型付け(Structural Typing) です。

つまり…

type UserId = string;
type OrderId = string;

は 全く同じ型 として扱われてしまいます。

これを避けたい(誤った代入を防ぎたい)ときに Brand 型を使います。

type Brand<K, T> = K & { __brand: T };

type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;

const u: UserId = "abc" as UserId;
const o: OrderId = "xyz" as OrderId;

const x: UserId = o;  // ❌ コンパイルエラー

「これは UserId であって、ただの string ではない」
という保証ができるので、単なる型定義でなく、その型自体に意味を持たせることができます。

11. Mapped Types を応用する

Mapped Types は TypeScript がすでに定義済みの型を使用すればおおよそ十分ですが、
自作することも可能です。

元々の定義を使う

オブジェクトのすべてのプロパティをオプショナルにする

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

type partialUser = Partial<User>
// { name: string | undefined; age: number | undefined }

すべてのプロパティに 読み取り専用にする

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

type readOnlyUser = Readonly<User>
// { readonly name: string; readonly age: number }
``

### 自作する
null許容にする
```ts
type Nullable<T> = {
  [K in keyof T]: T[K] | null;
};

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

type UserWithNullable = Nullable<User>;
// { name: string | null; age: number | null }

上記null許容なフィールド以外に必須フィールドを定義する

type UserExtra = {
  id: number;
  createdAt: Date;
  updatedAt: Date;
};

type UserWithManyRequired = Nullable<User> & ExtraFields;
// { id: number; name: string | null; age: number | null; createdAt: Date; updatedAt: Date; }

これは自作ジェネリクスに対して、別の型定義を組み合わせたやり方なので、前編でも記載したように、
型定義は演算によって作成されているということがよくわかります。

12. ユニオン型をオブジェクト型に変換して表現する

ユニオン型を崩したくないけど、その中身をオブジェクトとして定義したいといった場合に使用

type UnionToObject<T extends string> = {
  [P in T]: { type: P };
}[T];

type Status = "success" | "error";

type Response = UnionToObject<Status>;
// { type: "success" } | { type: "error" }

ケースとしてはさほど多くなくレアケースかと思いますが、覚えておいて損はなさそうです。

13. 特定の値しか入らない「辞書型」を作る

const roles = ["admin", "user", "guest"] as const;

type RoleMap<T> = Record<typeof roles[number], T>;
const permissions: RoleMap<boolean> = {
  admin: true,
  user: false,
  guest: false,
};

これには以下のメリットがあると考えています。

  • Record<Keys, Type> を使用した方が、経験値に左右されず、直感的に読みやすい。
  • 仮に roles が元から定義してあった場合、元の定義を参照するように型定義することで、一貫性を担保できるようになる。

ただし、型定義を厳密に扱い柔軟性を考慮したい場合は以下の方が適していると思います。

type RoleMap2<T> = { [K in typeof roles[number]]: T };

例えば、admin以外はオプショナルにしたいといった場合、Recordのみだとできませんが、
mapped typeを含む型定義を使えばできます。

type StrictRoleMap<T> = {
  admin: T;     // 必須
} & {
  [K in Exclude<Role, "admin">]?: T; // optional
};

const permissions2: StrictRoleMap<boolean> = {
  admin: true,
//   user: false,
//   guest: false,
};
// OK

14. Pick / Omit / Extract / Exclude を組み合わせて型演算を行う(なんだかんだ一番使う)

Pick:必要なプロパティだけ取り出す

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

type UserSummary = Pick<User, "id" | "name">;

Omit:逆に除外する

type UserWithoutAge = Omit<User, "age">;

Exclude:union から削除

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

Extract:union から抽出

type B = Extract<"a" | "b" | "c", "a" | "c">;
// "a" | "c"

実務では、API のレスポンスから「更新不可フィールド」を除去するみたいなことをやります。

type EditableUser = Omit<User, "id" | "createdAt">;

まとめ(後編)

後編では TypeScript前編で取り上げた内容も駆使しながら、実務で使用する定義例をいくつか紹介しました。

本記事で紹介したものはほんの一部です。
なぜなら、型定義は演算ということもあり、柔軟に定義できる性質上、こういったケースではこの定義の方が適しているといった場面が無数に存在するためです。

しかし、これまで取り上げた定義は基本的には実務で使用可能なものかと思います。

また、複雑すぎる型定義は解読にコストがかかる点や、経験値の差によって開発効率の低下につながる可能性を考慮すると、必ずしも柔軟だからといって複雑な定義をするのはシンプルで読みやすいコードを書く上では選択するべきではないと考えています。

そのため、今回紹介したものが扱えるレベルになれれば、一旦は良いのではないかという考えに至りました。

ネットに転がっている有識者(猛者)たちの情報も今後参考にしながら、
型とお友達になりたいと改めて思いました。(今回の記事を書いての感想)

今回の記事がお役にたてたら幸いです。

NCDC テックブログ

Discussion