【実務・中級編】関数の引数における「Exact Optional Property Types」の有効化と挙動の理解 – TypeScript コア・型システムの基礎解析バイブル

禁断の `undefined` を制する者だけが、堅牢なTypeScriptシステムを築ける! `exactOptionalPropertyTypes` の真髄

Webエンジニア諸君、日々のフロントエンド開発、コンポーネント設計、そして非同期API連携に、どれほどの情熱を注いでいるだろうか? 我々は常に、バグの温床となりうる落とし穴を避け、堅牢で保守性の高いコードを追求しなければならない。そのために、TypeScriptは強力な武器となる。しかし、その武器を真に使いこなせているか?

今日は、TypeScriptの型システムにおける、ある「隠された真実」に光を当てたい。それは、関数の引数における「オプショナルプロパティ」の扱いに潜む、微妙でありながらも決定的な違いだ。特に、`tsconfig.json` で `exactOptionalPropertyTypes` を有効にした際の挙動は、多くの開発者が見落としがちな、しかし極めて重要なポイントである。

この設定一つで、コードの安全性は劇的に向上する。だが、その真価を理解せず、漫然とオプショナルプロパティを使っていると、予期せぬバグに苦しむことになるだろう。本稿では、その深淵を覗き込み、実務で即座に応用可能な、美しくも堅牢なコードパターンを伝授する。

オプショナルプロパティの甘い誘惑と、それに潜む罠

まずは、基本から確認しよう。TypeScriptにおいて、関数の引数やオブジェクトのプロパティがオプショナルであることは、プロパティ名の後に `?` をつけることで表現できる。

interface UserProfile {
name: string;
age?: number; // age はオプショナル
}

function greetUser(profile: UserProfile) {
console.log(`Hello, ${profile.name}!`);
if (profile.age !== undefined) {
console.log(`You are ${profile.age} years old.`);
}
}

このコードは、`age` が存在しない場合(つまり `undefined` である場合)も考慮して、安全にアクセスしている。さて、ここで問題だ。以下の呼び出しは、型安全だろうか?

// ケース1: オプションのプロパティを省略
greetUser({ name: “Alice” });

// ケース2: オプションのプロパティに undefined を明示的に渡す
greetUser({ name: “Bob”, age: undefined });

多くの開発者は、これらの呼び出しが等価であると無意識に考えているだろう。しかし、`exactOptionalPropertyTypes` が `false` (デフォルト値) の場合、TypeScriptはこれらの呼び出しを許可する。そして、ここに罠が潜んでいる。

`exactOptionalPropertyTypes` の覚醒:undefined を「値」として扱う

`exactOptionalPropertyTypes` を `true` に設定すると、TypeScriptの型チェックはより厳格になる。この設定の核心は、オプショナルプロパティが `undefined` であることと、そのプロパティが存在しないことを、TypeScriptが厳密に区別するようになることだ。

`exactOptionalPropertyTypes: false` (デフォルト) の挙動

`false` の場合、オプショナルプロパティ `key?: T` は、実質的に `key: T | undefined` と等価のように扱われる。つまり、プロパティが存在しない場合も、`undefined` が明示的に渡された場合も、同じ型として扱われる。

// tsconfig.json で exactOptionalPropertyTypes: false (または未設定) の場合

interface UserProfileDefault {
name: string;
age?: number; // 実質: age: number | undefined
}

function greetUserDefault(profile: UserProfileDefault) {
console.log(`[Default] Hello, ${profile.name}!`);
// profile.age が undefined であるか、存在しないかは区別されない
if (profile.age !== undefined) {
console.log(`[Default] You are ${profile.age} years old.`);
} else {
console.log(`[Default] Age information is missing.`);
}
}

// どちらの呼び出しも許可される
greetUserDefault({ name: “Charlie” }); // age が省略
greetUserDefault({ name: “David”, age: undefined }); // age に undefined を明示的に設定

// 実行結果はどちらも同じになる
// [Default] Hello, Charlie!
// [Default] Age information is missing.
// [Default] Hello, David!
// [Default] Age information is missing.

この挙動は、一見便利に思えるかもしれない。しかし、APIレスポンスや状態管理などで、プロパティの「欠如」と「明示的な `undefined`」が異なる意味を持つ場合、この緩い型付けはバグの温床となる。例えば、APIが `age` フィールドを返さなかった場合と、`age: null` (これは `undefined` とは異なるが、同様の曖昧さを持つ) や `age: undefined` を返した場合を区別したい、といったシナリオが考えられる。

`exactOptionalPropertyTypes: true` の覚醒

`true` に設定すると、この曖昧さが解消される。オプショナルプロパティ `key?: T` は、「`key` が存在しない」という状態と、「`key` が `undefined` という値を持つ」という状態を、型システム上で明確に区別するようになる。

// tsconfig.json で exactOptionalPropertyTypes: true の場合

interface UserProfileStrict {
name: string;
age?: number; // age は「存在しない」か、あるいは「number 型の値を持つ」
// age: undefined は、このインターフェースの定義では許容されない
}

// 以下の関数定義は、exactOptionalPropertyTypes: true の環境ではエラーになる!
/
function greetUserStrictWithError(profile: UserProfileStrict) {
console.log(`[Strict] Hello, ${profile.name}!`);
if (profile.age !== undefined) { // このチェック自体は問題ない
console.log(`[Strict] You are ${profile.age} years old.`);
} else {
console.log(`[Strict] Age information is missing.`);
}
}
/

// 正しい関数定義の例: `undefined` を許容する場合は、明示的に型に含める
interface UserProfileStrictCorrect {
name: string;
age?: number | undefined; // age は「存在しない」か、あるいは「number 型の値を持つ」、あるいは「undefined という値を持つ」
}

function greetUserStrict(profile: UserProfileStrictCorrect) {
console.log(`[Strict] Hello, ${profile.name}!`);
// profile.age が undefined であるか、存在しないかは区別される
if (profile.age !== undefined) {
console.log(`[Strict] You are ${profile.age} years old.`);
} else {
console.log(`[Strict] Age information is missing.`);
}
}

// ケース1: オプションのプロパティを省略 (age は「存在しない」)
// greetUserStrict({ name: “Eve” }); // これはエラーになる! UserProfileStrictCorrect では age: undefined を許容するが、
// age が「存在しない」ことを明確に表現するには、
// undefined を明示的に渡すか、`age?: number` の定義では age が省略されるべき。
// ここで混乱を招きやすいが、`exactOptionalPropertyTypes` の真髄は、
// `undefined` を「値」として明示的に扱うことにある。

// より正確な例: `age?: number` の場合、`undefined` は「値」として渡せない。
// 存在しないことを期待される。

interface UserProfileOnlyNumber {
name: string;
age?: number; // 「number」であるか、あるいは「存在しない」
}

function greetUserStrictNumber(profile: UserProfileOnlyNumber) {
console.log(`[StrictNumber] Hello, ${profile.name}!`);
if (profile.age !== undefined) {
console.log(`[StrictNumber] You are ${profile.age} years old.`);
} else {
console.log(`[StrictNumber] Age information is missing.`);
}
}

// `UserProfileOnlyNumber` の場合、`age: undefined` は型エラーになる
// greetUserStrictNumber({ name: “Frank”, age: undefined }); // Error: Argument of type ‘{ name: string; age: undefined; }’ is not assignable to parameter of type ‘UserProfileOnlyNumber’. Object literal may only specify known properties, and ‘age’ does not exist in type ‘UserProfileOnlyNumber’.

// `age` が省略された場合は、`profile.age` は `undefined` になる。
greetUserStrictNumber({ name: “Grace” }); // OK

// `age` が `number` の値を持つ場合
greetUserStrictNumber({ name: “Heidi”, age: 30 }); // OK

// 実行結果
// [StrictNumber] Hello, Grace!
// [StrictNumber] Age information is missing.
// [StrictNumber] Hello, Heidi!
// [StrictNumber] You are 30 years old.

ここが肝要である。 `exactOptionalPropertyTypes: true` の世界では、オプショナルプロパティ `key?: T` は、そのプロパティが「存在しない」か、あるいは「`T` 型の値を持つ」かのどちらか、と解釈される。`undefined` を明示的に渡すことは、この「存在しない」という状態を上書きし、「`undefined` という値を持つ」という、より具体的な状態を表現することになる。

そのため、`exactOptionalPropertyTypes: true` 環境下で、`key?: T` と定義されたプロパティに `undefined` を渡そうとすると、型エラーが発生する。これが、`undefined` を「値」として明示的に渡すことと、「省略」することの型安全性の違いである。

どのような時に `exactOptionalPropertyTypes` を有効にすべきか?

1. APIレスポンスの厳密なハンドリング:
外部APIから受け取るJSONデータでは、フィールドが存在しない場合と、フィールドの値が `null` や `undefined` である場合が、API仕様として区別されていることがある。`exactOptionalPropertyTypes: true` は、この区別をTypeScriptの型レベルで強制し、予期せぬ `undefined` によるバグを防ぐ。

2. コンポーネントのProps設計:
Reactなどのコンポーネントライブラリでは、propsの設計が重要だ。あるpropsが「省略可能」なのか、「値として `undefined` が渡される可能性がある」のかを明確にしたい場合に有効。例えば、`Button` コンポーネントで `disabled` prop が `true` の場合はボタンが無効化されるが、`disabled` prop が 存在しない 場合と `disabled={undefined}` が渡された場合で、UIの挙動を変えたい(通常は両者とも無効化されるが、より厳密な制御が必要な場合)といった、高度なシナリオで役立つ。

3. 状態管理の整合性:
ReduxやZustandなどの状態管理ライブラリで、Stateオブジェクトのプロパティが「未設定」であることと、「`undefined`」であることを区別したい場合に有効。これにより、状態の初期化ロジックや更新ロジックがより堅牢になる。

実務で使える!堅牢なプロダクションコード例

ここでは、`exactOptionalPropertyTypes: true` を活用し、堅牢で保守性の高いコードパターンを、いくつか提示しよう。

例1: ユーザー設定の更新API連携

ユーザー設定APIは、更新したいフィールドのみをリクエストボディに含める設計になっていると仮定する。`null` や `undefined` を明示的に設定することで、そのフィールドを「クリア(削除)」する、あるいは「`undefined` にする」といった意図を明確にしたい。

// tsconfig.json に以下を追加
// “exactOptionalPropertyTypes”: true

/

  • ユーザー設定更新リクエストボディの型
  • exactOptionalPropertyTypes: true により、
  • `age` は「number型である」か、「存在しない」かのどちらか。
  • `email` は「string型である」か、「undefinedである」か、「存在しない」のいずれか。
  • ※ `email?: string | undefined` と定義することで、`undefined` を値として許容する。

/
interface UpdateUserRequest {
name?: string;
age?: number; // age は number か、存在しない。 undefined は直接渡せない。
email?: string | undefined; // email は string, undefined, または存在しない。
isActive?: boolean; // isActive は boolean か、存在しない。
}

/

  • ユーザー設定を更新するAPIクライアント関数
  • @param userId – 更新対象のユーザーID
  • @param updates – 更新内容
  • @returns 更新後のユーザー情報

/
async function updateUser(userId: string, updates: UpdateUserRequest): Promise {
console.log(`Sending update request for user ${userId}:`, updates);
// ここで実際にAPIリクエストを行う(例: fetch API)
// const response = await fetch(`/api/users/${userId}`, {
// method: ‘PATCH’,
// headers: { ‘Content-Type’: ‘application/json’ },
// body: JSON.stringify(updates),
// });
// if (!response.ok) {
// throw new Error(‘Failed to update user’);
// }
// return response.json();

// ダミーのレスポンス
return {
id: userId,
…updates, // 実際にはAPIからのレスポンスを返す
};
}

// — 実用的な利用例 —

async function manageUserProfile() {
const userId = “user-123”;

// 1. 名前のみ更新
await updateUser(userId, { name: “Alice Updated” });
// Console: Sending update request for user user-123: { name: ‘Alice Updated’ }

// 2. 年齢をクリア(APIが `age: null` や `age: undefined` を受け付ける場合)
// `age?: number` の定義では `age: undefined` は渡せないため、
// API側が `null` を受け付けるなら、`age: null` と明示するか、
// `UpdateUserRequest` の `age` の型を `number | null | undefined` などに変更する必要がある。
// ここでは、`email` を `undefined` にする例を示す。
await updateUser(userId, { email: undefined });
// Console: Sending update request for user user-123: { email: undefined }

// 3. アクティブ状態を false にし、メールアドレスをクリア
await updateUser(userId, { isActive: false, email: undefined });
// Console: Sending update request for user user-123: { isActive: false, email: undefined }

// 4. name をクリアしたい場合 (APIが `name: null` を受け付ける仕様の場合)
// `name?: string` の定義では `name: undefined` も `name: null` も直接渡せない。
// `UpdateUserRequest` の `name` を `string | null | undefined` のように定義する必要がある。
// 例: `name?: string | null`
// await updateUser(userId, { name: null }); // `name?: string | null` の場合のみOK
}

manageUserProfile();

解説:
`exactOptionalPropertyTypes: true` を有効にすることで、`age?: number` は「`age` は `number` 型であるか、あるいはプロパティが存在しない」という状態を表現します。もし `age` に `undefined` を渡したいのであれば、`age?: number | undefined` のように明示的に `undefined` を型に含める必要があります。
この厳密さにより、「`age` が未設定」であることと、「`age` が `undefined` という値を持つ」ことが、型レベルで区別できるようになり、APIとの連携における曖昧さを排除できます。

例2: ReactコンポーネントのProps設計

カスタムフックやコンポーネントで、オプションのpropsが「指定されない」場合と、「`undefined` が指定される」場合で、挙動を区別したいケース。

// tsconfig.json に以下を追加
// “exactOptionalPropertyTypes”: true

// — カスタムフックの例 —

interface UseModalOptions {
initialOpen?: boolean; // true または false (存在しない)
closeOnOutsideClick?: boolean | undefined; // true, false, または undefined (明示的に指定)
}

/

  • モーダル表示を制御するカスタムフック
  • @param options – モーダル表示オプション

/
function useModal(options: UseModalOptions = {}) {
const {
initialOpen = false, // default value for `initialOpen` if not provided
closeOnOutsideClick = true // default value for `closeOnOutsideClick` if not provided
} = options;

// — 内部状態とロジック —
const [isOpen, setIsOpen] = React.useState(initialOpen);

// `closeOnOutsideClick` の挙動を `undefined` の場合と、
// 省略された場合 (default値として `true` になる) で区別したい場合。
// ただし、この例では `undefined` の場合も `true` の場合も同じ挙動にする。
// より厳密な制御が必要な場合は、`closeOnOutsideClick` の処理を分ける。

const handleClose = () => {
// `closeOnOutsideClick` が `false` でない限り、閉じる
if (closeOnOutsideClick !== false) {
setIsOpen(false);
console.log(‘Modal closed.’);
} else {
console.log(‘Modal click outside disabled.’);
}
};

// — 実際の利用例 —
console.log(`Modal initialized. Initial open: ${initialOpen}, Close on outside click: ${closeOnOutsideClick}`);

return { isOpen, handleClose, setIsOpen };
}

// — コンポーネントでの利用例 —
function MyComponent() {
// 1. オプションを一切指定しない場合
const modal1 = useModal(); // initialOpen: false, closeOnOutsideClick: true (default)
console.log(‘Modal 1 state:’, modal1.isOpen); // false

// 2. `initialOpen` のみを指定
const modal2 = useModal({ initialOpen: true }); // initialOpen: true, closeOnOutsideClick: true (default)
console.log(‘Modal 2 state:’, modal2.isOpen); // true

// 3. `closeOnOutsideClick` に `false` を明示的に指定
const modal3 = useModal({ closeOnOutsideClick: false }); // initialOpen: false (default), closeOnOutsideClick: false
console.log(‘Modal 3 state:’, modal3.isOpen); // false
// modal3.handleClose(); // Click outside disabled.

// 4. `closeOnOutsideClick` に `undefined` を明示的に指定
// `exactOptionalPropertyTypes: true` 環境では、`closeOnOutsideClick?: boolean | undefined` の型定義が必須。
// そして、`useModal` の `options` のデストラクチャリングでデフォルト値が設定される。
// `closeOnOutsideClick = true` は、`undefined` の場合でも `true` をデフォルト値として採用する。
// より厳密な制御をするには、デストラクチャリングのデフォルト値を `undefined` にしない、
// または `options.closeOnOutsideClick === undefined` のようなチェックを別途行う必要がある。
const modal4 = useModal({ closeOnOutsideClick: undefined }); // initialOpen: false (default), closeOnOutsideClick: true (default value applies to undefined)
console.log(‘Modal 4 state:’, modal4.isOpen); // false
// modal4.handleClose(); // Modal closed. (Because default value `true` is applied)

// 5. `closeOnOutsideClick` に `true` を明示的に指定
const modal5 = useModal({ closeOnOutsideClick: true }); // initialOpen: false (default), closeOnOutsideClick: true
console.log(‘Modal 5 state:’, modal5.isOpen); // false
// modal5.handleClose(); // Modal closed.

// — `closeOnOutsideClick` が `false` の場合にのみ閉じる、という厳密な制御 —
// この場合、`options` の型定義と、フック内部のロジックを調整する必要がある。

interface StrictModalOptions {
initialOpen?: boolean;
closeOnOutsideClick?: boolean; // `undefined` を許容しない
}

function useStrictModal(options: StrictModalOptions = {}) {
const {
initialOpen = false,
closeOnOutsideClick = true // `undefined` の場合は `true` になる
} = options;

const [isOpen, setIsOpen] = React.useState(initialOpen);

const handleCloseStrict = () => {
// `closeOnOutsideClick` が `false` である場合のみ、閉じない。
// それ以外 (true, または default の true) の場合は閉じる。
if (closeOnOutsideClick === false) {
console.log(‘Strict Modal: Click outside disabled.’);
// setIsOpen(false); // 実際には閉じる処理をここに
} else {
setIsOpen(false);
console.log(‘Strict Modal: Closed.’);
}
};
console.log(`Strict Modal initialized. Initial open: ${initialOpen}, Close on outside click: ${closeOnOutsideClick}`);
return { isOpen, handleCloseStrict, setIsOpen };
}

console.log(“— Strict Modal Example —“);
const strictModal1 = useStrictModal(); // initialOpen: false, closeOnOutsideClick: true
// strictModal1.handleCloseStrict(); // Strict Modal: Closed.

const strictModal2 = useStrictModal({ closeOnOutsideClick: false }); // initialOpen: false, closeOnOutsideClick: false
// strictModal2.handleCloseStrict(); // Strict Modal: Click outside disabled.

// `closeOnOutsideClick?: boolean` の定義なので、`undefined` は渡せない。
// const strictModal3 = useStrictModal({ closeOnOutsideClick: undefined }); // Error!

return null; // Dummy return
}

// React.js が必要なので、ここでは実行せずに型定義とロジックの概念を示す。
// `React` is a placeholder for the actual React import.
const React = { useState: (initial: any) => [initial, () => {}] };
MyComponent();

解説:
`exactOptionalPropertyTypes: true` は、Propsの設計において「この値は省略されても良い」という状態と、「この値は `undefined` である」という状態を明確に区別させます。
`UseModalOptions` インターフェースで `initialOpen?: boolean` と定義した場合、`initialOpen` は `boolean` 値を持つか、プロパティ自体が存在しないかのどちらかです。`undefined` を渡そうとすると型エラーになります。
一方、`closeOnOutsideClick?: boolean | undefined` と定義すれば、`true`, `false`, `undefined` のいずれも許容されます。
カスタムフックやコンポーネントの内部で `options.closeOnOutsideClick` を参照する際、`undefined` と `true` の挙動を厳密に区別したい場合は、`options` の型定義と、デストラクチャリングのデフォルト値の扱いを慎重に設計する必要があります。

パフォーマンスに関する注意点

`exactOptionalPropertyTypes` は、コンパイル時の型チェックを厳格にするための設定であり、実行時のパフォーマンスに直接的な影響を与えることはほとんどありません。TypeScriptの型情報は、JavaScriptにコンパイルされる際には基本的に削除されるため、実行コードはよりシンプルになります。

しかし、間接的な影響として、型安全性が向上することで、実行時エラーの可能性が低減し、結果としてデバッグや予期せぬクラッシュからの回復にかかる時間を削減できる可能性はあります。これは、保守性や開発効率という観点でのパフォーマンス向上と言えるでしょう。

まとめ:堅牢なシステムを築くための、TypeScriptとの対話

`exactOptionalPropertyTypes` を `true` に設定することは、TypeScriptの型システムとの対話を、より深く、より厳密にする行為です。それは、開発初期段階で潜在的なバグを発見し、コードの意図をより明確にするための強力な手段となります。

確かに、この設定を有効にすると、初期段階では型エラーに悩まされることがあるかもしれません。しかし、それはTypeScriptが「あなたのコードの意図をより正確に理解しようとしている」証拠です。そのエラーメッセージに真摯に向き合い、型定義を適切に修正していくことで、バグの起きない、堅牢で保守性の高いプロダクションコードが、あなたの手によって築き上げられていくのです。

フロントエンド、バックエンド、API連携、そのすべてにおいて、TypeScriptの型システムを最大限に活用し、より信頼性の高いアプリケーション開発を目指しましょう。この `exactOptionalPropertyTypes` の理解が、その一助となれば幸いです。

タイトルとURLをコピーしました