TypeScript の型定義をよりよくするための考え方

こんにちは、 Gaji-Labo フロントエンドエンジニアの鈴木です。

TypeScript 導入初期は VS Code などのエディターの警告に言われるがまま型修正をして、どうにかコンパイルを通すというようなことも多いと思います。
今回はそんな状態から半歩踏み込んで型定義をよりよくするための考え方を単純なサンプルを交えて解説します。

デモ環境

CodeSandbox に下記のようなサンプルを用意しました。

サンプルでは role というシステム権限とその情報をもったオブジェクトに「管理者」「編集者」「閲覧者」の情報を格納しています。そして、userRole に現在の権限を指定します。
これらに型定義を付与していきます。

ステップ0 型推論に任せる

まずは型定義を行わず JavaScript と変わらない定数宣言をしています。

const userRole = "admin";
const role = {
  admin: {
    label: "管理者",
    color: "red",
    description: "管理者権限をもつユーザーです"
  },
  editor: {
    label: "編集者",
    color: "green",
    description: "編集権限をもつユーザーです"
  },
  viewer: {
    label: "閲覧者",
    color: "blue",
    description: "閲覧専用ユーザーです"
  }
};

role や userRole を単一のスコープ内のみで使用する想定であれば型推論が適切に効くため、型指定がなくてもコンパイルが出来ます。
場合によっては雑な型指定を行うよりも推論に任せたほうが安全な場合もあります。
しかし、 React コンポーネントの props などで外部から様々なデータを受け取る想定の場合、受け取るデータにあわせた型指定を行う必要があります。

ステップ1 とりあえず型指定

まずはとにかく props で受け取れるように role の型 Role を指定してみました。

interface Role {
  admin: {
    label: string;
    color: string;
    description: string;
  };
  editor: {
    label: string;
    color: string;
    description: string;
  };
  viewer: {
    label: string;
    color: string;
    description: string;
  };
}

突然 TypeScript を書くことになり勉強をする余裕がなく VS Code に言われるがまま型を書くとこんな型指定になるかもしれません。
とりあえず role の構造をそのまま型に反映した状態ですね。

ステップ2 繰り返し部分をまとめて簡略化する

ステップ1の型指定では admineditorviewer の中身(value)が共通しているため冗長です。
このステップでは Role の key の部分が string になることに着目して少し型の制約を緩めることで簡略化してみます。

interface Role {
  [key: string]: {
    label: string;
    color: string;
    description: string;
  };
}

admin など一個ずつ丁寧に指定していた key を [key: string] とすることで、 string であればどんな key でも指定できるようにしました。

ステップ3 Role の種別を別に定義し、厳密かつ把握しやすくする

ステップ2では key の制約を緩めることで型定義を簡略化しました。
システム上に様々な Role が存在し、フロントエンドのコードではどんな Role が指定されるか分からない場合はこれで問題ありません。
しかし、今回は admineditorviewer の3種で確定しています。ステップ2の状態ではそれ以外の Role も受け付けてしまうため安全ではありません。

ということで、新たに RoleType という型を定義して Role の key を RoleType のみ受け付けるように修正します。

type RoleType = "admin" | "editor" | "viewer";

type Role = {
  [key in RoleType]: {
    label: string;
    color: string;
    description: string;
  };
};

RoleType はユニオンタイプを使用して admineditorviewer という string のみを受け付ける型定義です。
Role の [key: string] となっていた箇所は [key in RoleType] とすることで、RoleType のみを key として受け付けるようにしました。
このとき Role の型宣言も interface から type に変えてあることに注意です。interface では key に string か undefined しか指定できず、この例のような他の型定義を利用する場合は type で宣言する必要があります。

これで、ステップ2でゆるくなった制約を再度厳密にしステップ1と同じ状態になりました。
Role と RoleType から「Roleはどんな構造か」と「想定される Role の種別は何か」が型から読み取りやすくなりました。ステップ1と同じことを宣言していますが、指定できる権限に着目することで意図が伝わりやすくなったかと思います。

別のアプローチ

サンプルコードは用意していませんが、value の部分が共通していることに着目して RoleDetail のような型を作成するアプローチもあります。

interface RoleDetail {
    label: string;
    color: string;
    description: string;
}
interface Role {
  admin: RoleDetail;
  editor: RoleDetail;
  viewer: RoleDetail;
}

こちらのほうが [key: string] などの知識がなくても使えて魅力的ですが、今回のような例では key 部分の RoleType こそ重要な情報ではと考え、ステップ3のようなアプローチを取りました。
書き方そのものに優劣はないので、どこに着目して型定義するかで使い分けるのが重要です。

また labelcolordescription を string としていますが、ここも想定するフォーマットがあるのであれば型を組み合わせてより厳密な型指定を行うと良さそうです。

おまけ 定数から型を抽出する

採用する機会は極稀かと思いますが、role が先に定義されておりそれに合わせた型定義を行いたい場合の例です。
as const で ROLE の全プロパティを readonly にし、一切改変されないようにしたうえで、それらの型情報を抽出します。

const ROLE = {
  admin: {
    label: "管理者",
    color: "red",
    description: "管理者権限をもつユーザーです"
  },
  editor: {
    label: "編集者",
    color: "green",
    description: "編集権限をもつユーザーです"
  },
  viewer: {
    label: "閲覧者",
    color: "blue",
    description: "閲覧専用ユーザーです"
  }
} as const;

type UserRole = keyof typeof ROLE;
type Role = typeof ROLE;

typeof と keyof のあわせ技で UserRole を宣言しています。これは下記のようにステップ3と同じ宣言です。

type UserRole = keyof typeof ROLE;
=> type UserRole = "admin" | "editor" | "viewer"

typeof ROLE はステップ1のような型情報を抽出しますが、 as const で readonly にしているためより厳密な型定義になります。as const していなければステップ1とまったく同じものになります。

type Role = {
    readonly admin: {
        readonly label: "管理者";
        readonly color: "red";
        readonly description: "管理者権限をもつユーザーです";
    };
    readonly editor: {
        readonly label: "編集者";
        readonly color: "green";
        readonly description: "編集権限をもつユーザーです";
    };
    readonly viewer: {
        readonly label: "閲覧者";
        readonly color: "blue";
        readonly description: "閲覧専用ユーザーです";
    };
}

厳密すぎて使いみちが難しいですが、たとえば別チームから提供された定数を元に、よしなに型を作って定数の変更でコードが壊れないか確認したいという場合などに使えるでしょうか。(こうやって書くと本当にニッチですね。。)

まとめ

VS Code などを使っていればパズルを解く感覚で型定義をしていくことはできますが、そこから一歩踏み出すには経験者のアドバイスや先人のエントリーなどで学習する必要があります。
同じ結果になる型でも、どこに着目して型定義をおこなうかでコードの意図の表現力が変わってくると思います。
今回の記事は初歩的な内容でしたが、これからも継続的な開発をスムースに行うためにも情報量が多く理解しやすい型定義を極めていきたいです。

Gaji-Laboでは、Jamstackが得意なフロントエンドエンジニアを募集しています

弊社ではJamstackの知見で事業作りに貢献したいフロントエンドエンジニアを募集しています。大きな制作会社や事業会社とはひと味もふた味も違うGaji-Laboを味わいに来ませんか?

もちろん、一緒にお仕事をしてくださるパートナーさんも随時募集中です。まずはお気軽に声をかけてください。お仕事お問い合わせや採用への応募、共に大歓迎です!

求人応募してみる!

投稿者 Suzuki

フロントエンドグループリード。
HTML/CSS のマークアップから始まり、現在は React や TypeScript を使ったコンポーネント実装をすることが多いです。淡々と実装するだけではなくコミュニケーションを取りながら、チームとしてプロジェクトを前に進めることを意識しています。最近は会社の成長へのコミットに関心があり、組織・チーム全体で強まるためにはどうするのだろう?ということを考えています。