tscが通っても型は嘘をつく|どこまで守ってくれるか実際に試した

tscが通っても型は嘘をつく|どこまで守ってくれるか実際に試した

any を禁止して strict を有効にすれば型は守られる、と思っていると足をすくわれます。TypeScriptの型注釈は、外から来るデータに対しては何も保証しません。

この記事では、tsc が止めてくれるコードと、素通りするコードの境界を実際に動かして確かめます。以下の出力はすべて手元で実行したものです(TypeScript 7.0.2 / zod 4.5.4)。

目次

tsc が止めてくれるもの

まず、ちゃんと検出される例から。よく書いてしまう3つを並べました。

type User = { id: number; name: string; role: "admin" | "member" };

// 1) 配列アクセスは必ず値が返ると思い込んでいる
function firstName(users: User[]): string {
  return users[0].name;
}

// 2) 文字列とIDが混ざる
function findUser(users: User[], id: string) {
  return users.find(u => u.id === id);
}

// 3) role の網羅チェックが漏れている
function label(role: User["role"]): string {
  switch (role) {
    case "admin": return "管理者";
  }
}
$ npx tsc --noEmit bad.ts --strict --noUncheckedIndexedAccess

bad.ts(5,10): error TS2532: Object is possibly 'undefined'.
bad.ts(10,26): error TS2367: This comparison appears to be unintentional
               because the types 'number' and 'string' have no overlap.
bad.ts(14,37): error TS2366: Function lacks ending return statement and
               return type does not include 'undefined'.

3つとも捕まえています。ただし1つ目は、noUncheckedIndexedAccess を有効にしていないと通ってしまいます。

このオプションがないと、TypeScriptは users[0] を無条件に User だと考えます。空配列なら実行時に undefined が返るのに、です。strict: true にこれは含まれていません。別途明示的に有効にする必要があります。

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,      // 配列・インデックスアクセスに undefined を含める
    "exactOptionalPropertyTypes": true     // 省略と undefined 代入を区別する
  }
}

「strictにしているから大丈夫」は正確ではありません。strict は出発点で、そこから何を追加で締めるかを決める必要があります。

tsc が何も言わないもの

問題はここからです。次のコードを見てください。

type User = { id: number; name: string; role: "admin" | "member" };

async function fetchUser(id: number): Promise<User> {
  const res = await fetch(`https://example.com/api/users/${id}`);
  return (await res.json()) as User;
}

export async function main() {
  const user = await fetchUser(1);
  console.log(user.name.toUpperCase());   // tsc 的には完全に安全
}
$ npx tsc --noEmit lie.ts --strict
$ echo $?
0

エラーは1つも出ません。それどころか、このコードはチーム内のレビューでも指摘されにくいはずです。型注釈がついていて、any も使っていません。

しかし res.json() の戻り値は any です。そこに as User と書くのは、検証ではなく「これは User だと思って扱え」とコンパイラに命令しているだけです。実際に届いたデータが何であろうと、tsc は確かめません。

つまり as は、型チェックを止めるスイッチです。any を禁止していても、as が残っていれば同じ穴が開きます。

工場の検品ライン。同じ形の無地の瓶が流れ、1本だけ倒れている写真
全部通ったように見えて、1本抜けている

実行時に何が起きるか

APIの仕様変更で name が nickname になり、id が文字列で返ってくるようになったとします。実際に動かすとこうなります。

// APIが返してきたデータ
{ id: "1", nickname: "taro", role: "owner" }

$ node demo.mjs
--- 型注釈だけの場合 ---
TypeError: Cannot read properties of undefined (reading 'toUpperCase')

このエラーの何が困るかというと、壊れた場所と原因の場所が離れていることです。

  • エラーが出るのは user.name.toUpperCase() の行
  • 本当の原因はAPIのレスポンス
  • 間に何層もコンポーネントを挟んでいれば、undefined はそのまま下まで運ばれ、まったく関係ない画面で落ちます

「APIが変わった」という一行の事実にたどり着くまでに、ログを追いかける時間がかかります。

境界で検証する

解決策は単純で、外から来たデータが自分のコードに入る境界で、実行時に検証することです。同じデータを zod に通すとこうなります。

const User = z.object({
  id: z.number(),
  name: z.string(),
  role: z.enum(["admin", "member"]),
});

const result = User.safeParse(fromApi);
--- zod で検証した場合 ---
id: Invalid input: expected number, received string
name: Invalid input: expected string, received undefined
role: Invalid option: expected one of "admin"|"member"

3つの不一致をすべて、フィールド名つきで指摘しています。しかもこれが出るのは、データを受け取った直後です。「APIのレスポンスが仕様と違う」ということが、その場で分かります。

外部の信用できないデータと内側の型を信じてよい領域を、境界での実行時検証が分けている図
検証を通した内側でだけ、型注釈が実態と一致する

検証する場所は、外部との境界すべてです。

  • APIのレスポンス
  • フォームの入力値
  • URLのクエリパラメータ・パスパラメータ
  • 環境変数
  • localStorage から読んだ値(古いバージョンのデータが残っていることがある)
  • Webhookのペイロード

逆に言えば、境界の内側では検証は要りません。入口で通してしまえば、あとは型を信じて書けます。全部の関数で防御的にチェックを書くのは、境界の設計ができていないことの裏返しです。

型は1か所から導く

ここで気をつけたいのが、スキーマと型を別々に書いてしまうことです。片方だけ直して、もう片方が古いまま残ります。

スキーマから型を導出すれば、二重管理がなくなります。

export const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  role: z.enum(["admin", "member"]),
});

// 型はスキーマから導出する
export type User = z.infer<typeof UserSchema>;

export async function fetchUser(id: number): Promise<User> {
  const res = await fetch(`https://example.com/api/users/${id}`);
  return UserSchema.parse(await res.json());   // ここで実行時に検証される
}

正しいのはスキーマのほうで、型はその影という関係にします。こうしておくと、スキーマを直せば型も自動で追従します。

網羅漏れを型で止める

ユニオン型を使うとき、いちばん怖いのはあとから選択肢が増えたときに、対応漏れが静かに残ることです。default で握りつぶしていると、気づかないまま本番に出ます。

これは never でコンパイルエラーに変えられます。

export function label(role: User["role"]): string {
  switch (role) {
    case "admin":  return "管理者";
    case "member": return "メンバー";
    default: {
      const never: never = role;   // すべて処理済みならここは never になる
      throw new Error(`未対応のrole: ${never}`);
    }
  }
}

実際に role に "guest" を1つ増やして、コンパイルしてみます。

$ npx tsc --noEmit infer2.ts --strict

infer2.ts(23,13): error TS2322: Type '"guest"' is not assignable to type 'never'.

スキーマに1つ足しただけで、対応が必要な場所がコンパイルエラーとして出てきます。選択肢が増える種類のデータでは、この書き方を最初から入れておく価値があります。

実行時に throw しているのは、検証を通していない経路から想定外の値が来た場合の保険です。型は実行時に存在しないので、コンパイルエラーだけでは実行時の安全は保証されません。

any を禁止するだけでは足りない

「any 禁止」はルールとして分かりやすいのですが、それだけだと今回のような穴は塞げません。実際に効くのは次の組み合わせです。

やること防げるもの
strict: truenull/undefined の取り違え、暗黙の any
noUncheckedIndexedAccess配列やオブジェクトの存在しない要素へのアクセス
as を禁止する型チェックの無効化。lintルールで検出できる
境界でのスキーマ検証外部データの形の食い違い
never による網羅チェック選択肢が増えたときの対応漏れ

どうしても型を絞り込めない値には、any ではなく unknown を使います。unknown は「何か分からない」という意味で、使う前に必ず絞り込みを強制されます。any は「何でもいい」なので、そのまま使えてしまいます。

まとめ

  • 型注釈は外部データを検証しない
    as User は願望であって保証ではない
  • tsc が通ることと、実行時に安全なことは別
    エラーが出ないコードのほうが危ないことがある
  • 検証は境界で1回
    内側は型を信じる。全関数で防御しない
  • スキーマを正として、型は導出する
    二重管理をやめる
  • ユニオンには never で網羅チェックを入れる
    選択肢が増えた瞬間に気づける
  • strict は出発点
    noUncheckedIndexedAccess は別途有効にする

次に読む記事

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

わどこんのアバター わどこん

実務12年のバックエンド・インフラエンジニア。バックエンド開発からクラウド・インフラの設計・構築・運用まで担当しています。主要言語は Java・Kotlin・PHP・Python。運用の現場で拾った知見を、再現できる手順に落として残すのがこのブログのテーマです。

目次