🌊

Typescriptにするだけで型安全になると思ってはいけない

に公開

はじめに

「TypeScriptを使えば型安全なコードが書ける」

多くの人はこんな期待を持って、コードを書き始めることでしょう。
しかしそのコードは、「型」の恩恵を受けているでしょうか?

例えば運動して痩せたいと思っている人を想像してみてください。「ジムに入会したから痩せるわ」と言われたらどうでしょう。
その返しはあえて言いませんが、それと同じです。
「Typescriptを使うだけ」で型安全になると思ってはいけません。

この記事では、よくあるTypeScriptのアンチパターンと、それを段階的に改善していく方法を、具体的なコード例とともに紹介します。

このコードの間違いは?

まずは、以下のコードを見てください。サッカー、野球、バスケットボールなどのアクティビティデータを登録するシステムです。
もちろんコンパイルエラーにはなっていませんが、実は少なくとも6つ以上のバグが潜んでいます。

せっかくなのでコードレビューだと思ってみてみましょう。

const registerActivityUseCase = (repository: IActivityRepository) => ({
  execute: async (activities: any[]) => {
    return await Promise.all(
      activities.map(async (activity) => {
        if (activity?.duration) {
          return await repository.registerSoccer({
            teamId: activity.teamId,
            duration: activity.duration,
            hasExtraTime: activity.hasExtraTime,
            teamName: activity.name1,
            leaderName: activity.name2,
          });
        } else if (activity?.innings) {
          return await repository.registerBaseball({
            teamId: activity.teamId,
            innings: activity.inings,
            isExtraInnings: activity.isExtraInnings,
            teamName: activity.name1,
            leaderName: activity.name2,
          });
        } else if (activity?.quarters) {
          await repository.registerBasketball({
            teamId: activity.teamId,
            quarters: activity.quorters,
            duration: activity.duration,
            teamName: activity.name1,
            leaderName: activity.name2,
          });
        }
      })
    );
  },
});

const ApiRouter = async (repository: IActivityRepository) => {
  const soccerLeader = "Leader 1";
  const soccerTeam = "Team 1";
  const baseballLeader = "Leader 2";
  const baseballTeam = "Team 2";
  const basketballLeader = "Leader 3";
  const basketballTeam = "Team 3";

  const useCase = registerActivityUseCase(repository);
  const result = await useCase.execute([
    {
      teamId: "1",
      duration: 100,
      hasExtraTime: true,
      name1: soccerLeader,
      name2: soccerTeam,
    },
    {
      teamId: "2",
      innings: "10",
      isExtraInnings: true,
      name1: baseballLeader,
      name2: baseballTeam,
    },
    {
      teamId: "3",
      quorters: 4,
      name1: basketballLeader,
      name2: basketballTeam,
    },
  ]);
};
参考:リポジトリの定義

(リポジトリは今回の本題ではないため、それなりに定義しています。)

export interface BaseActivity {
  teamId: string;
}

export interface SoccerActivity extends BaseActivity {
  duration: number;
  hasExtraTime: boolean;
  teamName: string;
  leaderName: string;
}

export interface BaseballActivity extends BaseActivity {
  innings: number;
  isExtraInnings: boolean;
  teamName: string;
  leaderName: string;
}

export interface BasketballActivity extends BaseActivity {
  quarters: number;
  duration: number;
  teamName: string;
  leaderName: string;
}

export interface IActivityRepository {
  registerSoccer: (data: SoccerActivity) => Promise<SoccerActivity>;
  registerBaseball: (data: BaseballActivity) => Promise<BaseballActivity>;
  registerBasketball: (data: BasketballActivity) => Promise<BasketballActivity>;
}

解答例

Typescriptに慣れ親しんだ大半の人は、anyを見かけたところで、読む気力がなくなったのではないでしょうか。
または、現場のあの人思い出して感情が高ぶった人もいるかもしれません。
一応の解答例は以下記載しておきます。

指摘事項の例

問題点:

  1. any[] なので型チェックが全く効かない
  2. activity.inings のtypo(正しくは innings)に気づけない
  3. activity.quorters のtypo(正しくは quarters)に気づけない
  4. name1name2 が何を意味するのか不明瞭(しかも逆に代入している!)
  5. バスケットボールのケースで return が抜けている
  6. 野球の innings: "10" が文字列なのに気づけない
  7. バスケットボールを登録しようとしても if (activity?.duration) でサッカーとして誤登録される可能性

リポジトリのインターフェースはきちんと型定義されているのに、実際のコードでは型の恩恵をまったく受けていません。

これでは「TypeScriptを使っている」というより「JavaScriptに型注釈をつけただけ」の状態ですね。

改善していく

Step 1: とりあえずアクティビティの型を定義する

まずは、any を何とかしましょう。

export interface IActivity {
  teamId: string;
  innings?: number; // 野球の時
  isExtraInnings?: boolean; // 野球の時
  hasExtraTime?: boolean; // サッカーの時
  quarters?: number; // バスケの時
  duration?: number; // サッカーの時
  name1: string;
  name2: string;
}

any[]IActivity[] に置き換え、interface を定義したことで出てきた型エラーを修正します。

const registerActivityUseCase = (repository: IActivityRepository) => ({
  execute: async (activities: IActivity[]) => {
    return await Promise.all(
      activities.map(async (activity) => {
        if (activity?.duration) {
          return await repository.registerSoccer({
            teamId: activity.teamId,
            duration: activity.duration,
            hasExtraTime: activity?.hasExtraTime ?? false,
            teamName: activity.name1,
            leaderName: activity.name2,
          });
        }
        // ...
      })
    );
  },
});


これでtypoや文字列と数値などの間違いはなくなりました。

しかし、本題はここからです。まだまだ問題が残っています。

つまり、「すべてのスポーツの属性を1つのインターフェースに詰め込んだだけ」なんです。これでは型で表現するのではなく、コメントに頼った開発になってしまっています。

プロパティの有無だけで型を判別するのは危険です。

Step 2: ユニオン型を導入する

次のステップの足がかりとしてユニオン型を導入しましょう。
これを使うと、プロパティの値を限定できます。

export interface IActivity {
  type: "soccer" | "baseball" | "basketball";
  teamId: string;
  innings?: number;
  isExtraInnings?: boolean;
  hasExtraTime?: boolean;
  quarters?: number;
  duration?: number;
  name1: string;
  name2: string;
}

そして判定ロジックを修正:

if (activity.type === "soccer") {
  return await repository.registerSoccer({
    teamId: activity.teamId,
    duration: activity.duration ?? 0,
    hasExtraTime: activity?.hasExtraTime ?? false,
    teamName: activity.name1,
    leaderName: activity.name2,
  });
}

これで意図的な区別ができるようになりました。

ここまで来ると、現場でよく見かけるコードになってきたのではないでしょうか。

しかしこれで満足してはいけません。type"soccer" なのに、TypeScriptは activity.durationnumber | undefined だと判断します。
本当は、durationが必須プロパティであるとすると、困ったことになります。
かといって、必須にしてしまうと、ほかのアクティビティでは必要ないので、それも問題です。

Step 2.5: as型アサーションの罠とsatisfies

if (activity.type === "soccer") {
  const data = {
    teamId: activity.teamId,
    duration: activity.duration,
    hasExtraTime: activity.hasExtraTime,
    teamName: activity.name1,
    leaderName: activity.name2,
  } as SoccerActivity;

  return await repository.registerSoccer(data);
}

少し余談ですが、型をはっきりさせるために「型アサーション」で解決しようとするケースがあります。
これで一応コンパイルは通りますが、かなり危険です。

as は「TypeScriptコンパイラよ、黙れ。俺を信じろ。」という魔法の呪文です。でも activity.duration が実際には undefined かもしれない状況で、それを無視してしまっています。

では、どうすればよいのでしょうか。そこで登場するのが satisfies です。

const data = {
  teamId: activity.teamId,
  duration: activity.duration ?? 0,
  hasExtraTime: activity.hasExtraTime ?? false,
  teamName: activity.name1,
  leaderName: activity.name2,
} satisfies SoccerActivity;

satisfies は「この値が指定した型を満たしているか検証してください」という意味です。as と違って、型が合わなければコンパイルエラーになります。

とはいえ、必須であってほしいパラメータについて、デフォルト値を指定しないといけないのは、まだ型システムが完全に仕事をしていない証拠です。

Step 3: 真の型安全へ - Union型の分離

さて、少し話題がそれましたが、サッカーでは必須プロパティだけど、ほかのスポーツでは必要がない値があったことについて思い出してください。
ここで本質的な解決策が登場します。次のようにIActivity を修正しましょう。

interface IActivityBase {
  teamId: string;
  name1: string;
  name2: string;
}

interface ISoccerActivity extends IActivityBase {
  type: "soccer";
  duration: number; // オプショナルじゃない!
  hasExtraTime?: boolean; // まあ、なくてもいいよ
}

interface IBaseballActivity extends IActivityBase {
  type: "baseball";
  innings: number; // オプショナルじゃない!
  isExtraInnings?: boolean; // まあ、なくてもいいよ
}

interface IBasketballActivity extends IActivityBase {
  type: "basketball";
  quarters: number; // オプショナルじゃない!
  duration: number; // オプショナルじゃない!
}

export type IActivity =
  | ISoccerActivity
  | IBaseballActivity
  | IBasketballActivity;

これで何が変わったでしょうか?

if (activity.type === "soccer") {
  const data = {
    teamId: activity.teamId,
    duration: activity.duration, // もう ?? 0 は不要!
    hasExtraTime: activity.hasExtraTime ?? false,
    teamName: activity.name1,
    leaderName: activity.name2,
  };

  return await repository.registerSoccer(data);
}

TypeScriptが自動的に型を絞り込んでくれます。

やり方は簡単。それぞれのアクティビティごとに、想定される型を定義して、それらをUnion型として結合するだけです。

こうするとactivity.type === "soccer" の条件内では、TypeScriptは「これは ISoccerActivity だ」と理解し、activity.duration は確実に number だと判断してくれます。

先ほどあった、「こっちでは必須だけどこっちでは不要なんだよな」がTypescriptによってサポートされます。

これこそが真の型安全です。

この型設計ができるか否かで、その後の開発体験がかなり変わってきます。

Step 4: リテラル型で制約を強化

さらに別の視点で改善していきましょう。
数値リテラル、文字リテラルの採用です。

interface ISoccerActivity extends IActivityBase {
  type: "soccer";
  duration: 45 | 90; // 前半45分、フルタイム90分のみ
  hasExtraTime?: boolean;
}

interface IBaseballActivity extends IActivityBase {
  type: "baseball";
  innings: 3 | 6 | 9; // 3イニング、6イニング、9イニングのみ
  isExtraInnings?: boolean;
}

interface IBasketballActivity extends IActivityBase {
  type: "basketball";
  quarters: 2 | 4; // 2クォーター、4クォーターのみ
  duration: 5 | 10; // 5分、10分のみ
}

これで duration: 100 のような不正な値を代入しようとすると、コンパイルエラーになります。

ビジネスルールを型で表現することで、実行時エラーをコンパイル時に検出できるようになります。

Step 4.5: ts-patternで網羅性チェック

ここでもう一つ余談です。ts-pattern の紹介です。

import { match } from "ts-pattern";

const registerActivityUseCase = (repository: IActivityRepository) => ({
  execute: async (activities: IActivity[]) => {
    return await Promise.all(
      activities.map(async (activity) => {
        return await match(activity)
          .with({ type: "soccer" }, async (activity) => {
            const data = {
              teamId: activity.teamId,
              duration: activity.duration,
              hasExtraTime: activity.hasExtraTime ?? false,
              teamName: activity.name1,
              leaderName: activity.name2,
            };
            return await repository.registerSoccer(data);
          })
          .with({ type: "baseball" }, async (activity) => {
            // ...
          })
          .with({ type: "basketball" }, async (activity) => {
            // ...
          })
          .exhaustive(); // 網羅性チェック!
      })
    );
  },
});

if-else の連鎖は、将来的に新しいスポーツが追加されたときにバグの温床になりますが、
ts-patternの .exhaustive() では、すべてのケースをカバーしていない場合はコンパイルエラーになります。
例えば、将来 "tennis" が追加されたとき、対応する .with({ type: "tennis" }, ...) を書き忘れるとエラーで教えてくれます。
switch 文に似ていますが、ts-patternはパターンマッチングが柔軟で、型安全に、しかも見通しよく書けます。

https://github.com/gvergnaud/ts-pattern

Step 5: 戻り値の型も守る

関数の戻り値も型安全にしましょう。
忘れていると思いますが、初めのコードでは条件分岐の一つだけreturnをつけ忘れていました。
それを検知するために関数に型定義をしていきます。

方法1: 明示的な型宣言

const registerActivityUseCase = (repository: IActivityRepository) => ({
  execute: async (
    activities: IActivity[]
  ): Promise<(SoccerActivity | BaseballActivity | BasketballActivity)[]> => {
    // ...
  },
});

これは一般的な手法です。
ただし、アクティビティが増えるとその分だけ型定義も増やさなければいけないので、少し手間がかかりますね。

方法2: satisfies で検証

const registerActivityUseCase = (repository: IActivityRepository) => ({
  execute: async (activities: IActivity[]) => {
    return (await Promise.all(
      // ...
    )) satisfies BaseActivity[];
  },
});

satisfies を使うことで、実際の戻り値が基底interfaceを満たしているか検証すると同時に、型推論によって、それぞれのアクティビティの型は保持されます。

方法3: 各ケースに型注釈

.with(
  { type: "soccer" },
  async (activity): Promise<SoccerActivity> => {
    // ...
  }
)

ts-pattern を使用する場合はそれぞれの関数に返り値を設定することもできます。

Step 6: Branded Typeで意味を持たせる

これで最後の仕上げです。もう一度最初のコードを見返してみてください。
(見返さなくていい)
name1name2 って何でしたっけ?
実はチーム名とリーダー名を表していたのですが、リポジトリに渡す際に入れ替わっていたことに気づいたでしょうか。

どちらもstring型なので、当然コンパイルエラーにはなりません。
かといって、DDDのようにValueObjectとしてわざわざクラスを作成するのは実装コストがかかります。

当然それを防ぐために変数名を適切につけましょうと習うところですが、型で検知できるならなお良しです。

馴染みがある方も少ないかもですが、ここで登場するのが Branded Type です。

Branded Type

export type UserName = string & { __brand: "UserName" };
export type TeamName = string & { __brand: "TeamName" };

interface を次のように修正することで、
name1UserName を代入しようとするとエラーになります。

interface IActivityBase {
  teamId: string;
  name1: TeamName; // チーム名だと明確!
  name2: UserName; // ユーザー名だと明確!
}

const soccerLeader = "Leader 1" as UserName;
const soccerTeam = "Team 1" as TeamName;

const activity: ISoccerActivity = {
  type: "soccer",
  teamId: "1",
  duration: 90,
  hasExtraTime: true,
  name1: soccerLeader, // エラー!name1はTeamName型です
  name2: soccerTeam,
};

ここで、「あれ、as でキャストしているのでは?」と思った方もいらっしゃるかもしれません。いい観点をお持ちですね。
as のキャストを使うのに抵抗がある方は、zod.brand() をおすすめします。
zod であればフロント開発ですでに使用されている方も少なくないと思いますので、導入しやすいでしょう。

ZodのBrandedType

import z from "zod";

export const userNameSchema = z.string().brand("UserName");
export const teamNameSchema = z.string().brand("TeamName");
export type UserName = z.infer<typeof userNameSchema>;
export type TeamName = z.infer<typeof teamNameSchema>;


interface IActivityBase {
  teamId: string;
  name1: TeamName;
  name2: UserName;
}

const soccerLeader = userNameSchema.parse("Leader 1")
const soccerTeam = teamNameSchema.parse("Team 1");

zodの良さは、パラメータの検証と統合できるので、無理なく導入することができることです。
BrandedType -> string はエラーにならないので、試しにつけて、ぜひ使い心地を試してみてください。

とはいえ、BrandedTypeも設計を間違えると、逆に混乱を招く恐れがありますので、全部につけようと思うのではなく、アプリケーションのコアとなる概念だけに絞って導入していくのが良いでしょう。

修正後のコード

今までの改善をすべて適用した最終的なコードは以下のようになります。

ドメインモデル(Activity型定義)
import type { TeamName, UserName } from "./domain";

/**
 * 共通のActivity基底クラス
 */
interface IActivityBase {
  teamId: string;
  name1: TeamName;
  name2: UserName;
}

/**
 * サッカーのActivityクラス
 */
interface ISoccerActivity extends IActivityBase {
  type: "soccer";
  duration: 45 | 90;
  hasExtraTime?: boolean;
}

/**
 * 野球のActivityクラス
 */
interface IBaseballActivity extends IActivityBase {
  type: "baseball";
  innings: 3 | 6 | 9;
  isExtraInnings?: boolean;
}

/**
 * バスケのActivityクラス
 */
interface IBasketballActivity extends IActivityBase {
  type: "basketball";
  quarters: 2 | 4;
  duration: 5 | 10;
}

export type IActivity =
  | ISoccerActivity
  | IBaseballActivity
  | IBasketballActivity;
Branded Type定義
export type UserName = string & { __brand: "UserName" };
export type TeamName = string & { __brand: "TeamName" };

または、zodを使った場合:

import z from "zod";

export const userNameSchema = z.string().brand("UserName");
export const teamNameSchema = z.string().brand("TeamName");
export type UserName = z.infer<typeof userNameSchema>;
export type TeamName = z.infer<typeof teamNameSchema>;
リポジトリインターフェース
import type { TeamName, UserName } from "./domain/domain";

export interface BaseActivity {
  teamId: string;
}

export interface SoccerActivity extends BaseActivity {
  duration: number;
  hasExtraTime: boolean;
  teamName: TeamName;
  leaderName: UserName;
}

export interface BaseballActivity extends BaseActivity {
  innings: number;
  isExtraInnings: boolean;
  teamName: TeamName;
  leaderName: UserName;
}

export interface BasketballActivity extends BaseActivity {
  quarters: number;
  duration: number;
  teamName: TeamName;
  leaderName: UserName;
}

export interface IActivityRepository {
  registerSoccer: (data: SoccerActivity) => Promise<SoccerActivity>;
  registerBaseball: (data: BaseballActivity) => Promise<BaseballActivity>;
  registerBasketball: (data: BasketballActivity) => Promise<BasketballActivity>;
}
ユースケース
import { match } from "ts-pattern";
import type { IActivity } from "./domain/activity";
import type { TeamName, UserName } from "./domain/domain";
import type { BaseActivity, IActivityRepository } from "./repository";

const registerActivityUseCase = (repository: IActivityRepository) => ({
  execute: async (activities: IActivity[]) => {
    return (await Promise.all(
      activities.map(async (activity) => {
        return await match(activity)
          .with({ type: "soccer" }, async (soccerActivity) => {
            const data = {
              teamId: soccerActivity.teamId,
              duration: soccerActivity.duration,
              hasExtraTime: soccerActivity.hasExtraTime ?? false,
              teamName: soccerActivity.name1,
              leaderName: soccerActivity.name2,
            };
            return await repository.registerSoccer(data);
          })
          .with({ type: "baseball" }, async (baseballActivity) => {
            const data = {
              teamId: baseballActivity.teamId,
              innings: baseballActivity.innings,
              isExtraInnings: baseballActivity?.isExtraInnings ?? false,
              teamName: baseballActivity.name1,
              leaderName: baseballActivity.name2,
            };
            return await repository.registerBaseball(data);
          })
          .with({ type: "basketball" }, async (basketballActivity) => {
            const data = {
              teamId: basketballActivity.teamId,
              quarters: basketballActivity.quarters,
              duration: basketballActivity.duration,
              teamName: basketballActivity.name1,
              leaderName: basketballActivity.name2,
            };
            return await repository.registerBasketball(data);
          })
          .exhaustive();
      }),
    )) satisfies BaseActivity[];
  },
});

const ApiRouter = async (repository: IActivityRepository) => {
  const soccerLeader = "Leader 1" as UserName;
  const soccerTeam = "Team 1" as TeamName;
  const baseballLeader = "Leader 2" as UserName;
  const baseballTeam = "Team 2" as TeamName;
  const basketballLeader = "Leader 3" as UserName;
  const basketballTeam = "Team 3" as TeamName;

  const useCase = registerActivityUseCase(repository);
  const result = await useCase.execute([
    {
      type: "soccer",
      teamId: "1",
      duration: 90,
      hasExtraTime: true,
      name1: soccerTeam,    // TeamName型
      name2: soccerLeader,  // UserName型
    },
    {
      type: "baseball",
      teamId: "2",
      innings: 6,
      isExtraInnings: true,
      name1: baseballTeam,
      name2: baseballLeader,
    },
    {
      type: "basketball",
      teamId: "3",
      quarters: 4,
      duration: 10,
      name1: basketballTeam,
      name2: basketballLeader,
    },
  ]);
};

まとめ:型安全への道のり

TypeScriptを導入しただけでは型安全にはなりません。以下のステップを踏むことで、真の型安全を実現できます。

  1. any を撲滅する - とにかく any は使わない
  2. インターフェースを定義する - ただしこれだけでは不十分
  3. Discriminated Unionで型を分離する - 複数の型を1つに詰め込まない
  4. as は避け、satisfies を使う - 型アサーションは最後の手段(ライブラリ側の問題で仕方ないときなど)
  5. リテラル型でビジネスルールを表現する - 不正な値を型レベルで排除
  6. ts-pattern で網羅性チェック - 将来の変更に強いコードを書く
  7. 戻り値の型も検証する - 入力だけでなく出力も型安全に
  8. Branded Type で意味を持たせる - プリミティブ型の混同を防ぐ

1〜5までは最低限意識したいラインですね。

おわりに

今回の内容を初めから完璧に設計するのは難しい場合があるかもしれません。
しかし型安全性は正しい知識を持って、適切な設計と継続的な改善をすることによって達成されるものです。

プロジェクトメンバーで共通認識をもってリファクタリングするためにも、今回得られた知識をぜひ周りの人に共有してみてください。

この記事が、あなたのTypeScriptライフをより型安全にする一助となれば幸いです。

Discussion