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 が残っていれば同じ穴が開きます。

実行時に何が起きるか
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: true | null/undefined の取り違え、暗黙の any |
noUncheckedIndexedAccess | 配列やオブジェクトの存在しない要素へのアクセス |
as を禁止する | 型チェックの無効化。lintルールで検出できる |
| 境界でのスキーマ検証 | 外部データの形の食い違い |
never による網羅チェック | 選択肢が増えたときの対応漏れ |
どうしても型を絞り込めない値には、any ではなく unknown を使います。unknown は「何か分からない」という意味で、使う前に必ず絞り込みを強制されます。any は「何でもいい」なので、そのまま使えてしまいます。
まとめ
- 型注釈は外部データを検証しない
as Userは願望であって保証ではない - tsc が通ることと、実行時に安全なことは別
エラーが出ないコードのほうが危ないことがある - 検証は境界で1回
内側は型を信じる。全関数で防御しない - スキーマを正として、型は導出する
二重管理をやめる - ユニオンには
neverで網羅チェックを入れる
選択肢が増えた瞬間に気づける strictは出発点noUncheckedIndexedAccessは別途有効にする
次に読む記事



