こんにちは!TypeScriptの世界へようこそ。
日々フロントエンドからバックエンドまでコードを書いていると、型システムとの対話に夢中になりますよね。
さて、今回はTypeScript初学者が必ずといっていいほどハマる、そして中級者でもうっかり見落としがちな「空オブジェクト型 `{}` の罠」について、コンパイラの内側で何が起きているのかという本質を含めて、優しく、そして深く解説していきますね。
ここをクリアすれば、TypeScriptの型安全性の解像度がグッと上がりますよ。ぜひ最後までお付き合いください!
—
1. 「空オブジェクト型 `{}`」って、どんな型?
TypeScriptを書き始めると、とりあえず何もないオブジェクトを表すために `{}` を使いたくなりますよね。例えば、こんなコードを書いたことはありませんか?
// 何もないオブジェクトを表現したい
const user: {} = { name: “Taro” };
「お、エラーが出ずに通った! `{}` って、空のオブジェクト(プロパティを持たないオブジェクト)専用の型なんだな」と思いますよね。
実はここが最初の大きな誤解なんです。
`{}` の本当の正体は「nullとundefined以外のすべて」
驚くべきことに、TypeScriptにおいて `{}` 型は、「空のオブジェクト」を表しているのではありません。「`null` と `undefined` 以外の、プリミティブ型を含むすべての値」を表しています。
ちょっと信じられないですよね? 実際の挙動を見てみましょう。
// 1. 文字列は入る?
const a: {} = “Hello, TypeScript!”; // エラーにならない!
// 2. 数値は入る?
const b: {} = 42; // これもエラーにならない!
// 3. 配列は入る?
const c: {} = [1, 2, 3]; // 当然エラーにならない!
// 4. 果ては関数まで!?
const d: {} = () => “hello”; // これすらエラーにならない!
嘘のようですが、これらはすべてTypeScriptのコンパイラをすり抜けて正常にコンパイルされます。
なぜこんなことになっているのでしょうか? TypeScriptの型システムの歴史と構造から、その理由を紐解いてみましょう。
—
2. なぜ `{}` は実質的になんでも許してしまうのか?
TypeScriptの型システムは、「構造的型付け(Structural Subtyping)」という仕組みをベースに動いています。「その値がどんな名前の型であるか」ではなく、「どんなプロパティを持っているか」で型の互換性を決めるアプローチです。
ここで `{}`(プロパティがゼロ個のオブジェクト型)の立場になって考えてみましょう。
`{}` が要求するのは、「プロパティが最低限ゼロ個以上あること」です。
数学的に考えると、どんな集合であっても、要素が0個の集合(空集合)は、あらゆる集合の部分集合(サブタイプ)になりますよね。
これと同じで、TypeScriptにおける `{}` は「何のプロパティも要求しない、もっとも緩い条件」を意味します。そのため、以下のような構図が成り立っています。
- `string` 型は、内部的に文字列のプロパティ(`.length` や `.toUpperCase()` など)を持っています。したがって、「プロパティがゼロ個以上ある」という `{}` の条件を満たします。
- `number` 型も同様です。
- `null` と `undefined`だけは、プロパティにアクセスしようとするとランタイムエラー(Cannot read properties of null)になるため、例外的に `{}` の世界から弾かれます。
つまり、 `{}` は実質的に `NonNullable
—
3. やってはいけない!よくあるアンチパターン
この `{}` の仕様を知らずにコードを書いていると、意図しないバグの温床になります。よくある失敗例を見てみましょう。
アンチパターン①:関数の引数に `{}` を使って「何でも受け取れる関数」を作ってしまう
// 「オブジェクトなら何でも受け取りたい」つもりで書いたコード
function printId(payload: {}) {
// console.log(payload.id); // 🔴 エラー! ‘{}’ 型に ‘id’ プロパティが存在しないと言われる
}
// でも、以下のような呼び出しができてしまう(型安全の崩壊)
printId(“just-a-string”);
printId(12345);
「オブジェクトだけを受け取りたい」と思ったのに、文字列や数値が渡せてしまい、さらにプロパティアクセスしようとすると型エラーになるという、非常にフラストレーションが溜まる状態になってしまいます。
—
4. 正しい使い分け:本当の「空オブジェクト」や「任意のオブジェクト」を表現するには?
「じゃあ、本当に空のオブジェクトや、オブジェクトだけを受け取りたいときはどう書けばいいの?」という疑問が湧きますよね。
目的に応じて、以下の3つの武器を使い分けられるようになりましょう。
① 本当に「空のオブジェクト(プロパティが一切存在してはならない)」を表現したい場合
もし「プロパティを1つも持たせたくない(`{}` にしたい)」という厳密な制約をかけたい場合は、`Record
// 本当にプロパティを持たない空オブジェクト専用の型
let strictEmpty: Record
// strictEmpty = { name: “Taro” }; // 🔴 エラー!プロパティを追加できない
// strictEmpty = “string”; // 🔴 エラー!プリミティブは代入できない
`never` という「絶対に値が存在しない」型を利用することで、「キーは文字列、値は絶対に存在しない(つまり何も持てない)」という鉄壁の空オブジェクト型を作り出すことができます。
② 「プリミティブ以外のすべてのオブジェクト」を表現したい場合
「文字列や数値は弾きたいけれど、配列や通常のオブジェクト、関数などは受け入れたい」という場合は、`object`(小文字の `o`)を使います。
const obj: object = { name: “Taro” }; // OK
const arr: object = [1, 2, 3]; // OK(配列もJSではオブジェクトの一種)
// const str: object = “hello”; // 🔴 エラー!プリミティブは弾かれる
JavaScriptの `typeof x === ‘object’` に近い感覚で扱えるため、プリミティブを排除したい場面で非常に重宝します。
③ 「任意のプロパティを持つオブジェクト(辞書型)」を表現したい場合
もし「キーと値のペアを自由に持てるオブジェクト」を表現したいなら、`Record
// どんなプロパティを持っていても良いが、アクセスした先の値は unknown として安全に扱う
const dictionary: Record
name: “Taro”,
age: 20,
};
// 安全に型ガード等を行ってから使う
if (typeof dictionary.name === “string”) {
console.log(dictionary.name.toUpperCase());
}
`any` を使う代わりに `unknown` を値に指定することで、型安全性を保ちながら柔軟なオブジェクト構造を表現できます。
—
まとめ:ここをクリアすればTypeScriptの基本はバッチリ!
今回は、TypeScriptの型システムにおける隠れた落とし穴「空オブジェクト型 `{}`」の本質について解説しました。
- `{}` は「空のオブジェクト」ではなく「nullとundefined以外のすべて」を表す。
- そのため、意図せずプリミティブ型(文字列や数値)を受け入れてしまう罠がある。
- 目的に応じて以下を使い分けるのがプロの技:
- 本当に空のオブジェクト:`Record
` - プリミティブ以外のオブジェクト:`object`
- 何でも入る辞書型:`Record
`
この違いをスッと理解できていると、ライブラリの型定義を読むときや、複雑なジェネリクスを組むときに迷わなくなります。
ここをクリアしたあなたなら、もうTypeScriptの基本の型で躓くことはありません。自信を持って次のステップに進んでくださいね!それでは、快適なTypeScriptライフを!