はじめに
こんにちは。フロントエンドの開発を担当しています、松岡です。
コードレビューをしている時 x === undefined
で比較してガードするケースをよく見かけます。
function something(x) {
if (x === undefined) {
return;
}
// 何らかの処理
// ...
}
x
が null
だった場合に意図しない結果を生み出すためこのような比較はしないで欲しいですとコメントすると、たいてい「どうして?この関数に null
が渡される事はないのに?」と不思議がられます。
レビューコメント用のちっさいテキストエリアでは説明しきれないので、なぜそのようなコメントをするのか私なりの持論をまとめてみました。
検証環境
記事内のサンプルコードは下記の環境で検証したものです。環境によりサンプルコードに記載したアウトプットが少し異なる事があります。ご了承ください。
- Node v15
- Chrome v91
- Firefox v89
undefined とは何か
undefined
は JavaScript のグローバル上に存在する変数です。in
演算子を使うとグローバルに undefined
が存在している事が分かります。
// Node.js(ブラウザでのアウトプットは異なります)
// undefinedはグローバルに存在する
undefined in globalThis
> true
よく使う下記のようなコードは、グローバル上に存在する undefined
という変数の値を代入する事を意味しています。
const x = undefined;
// これと同じ
const x = globalThis.undefined;
null とは何か
JavaScript には undefined
の他に、空を示す概念として null
も存在します。
null
はあらゆるオブジェクトの派生元です。Object.getPrototypeOf()
で派生元を調べると null
に行き着くことが分かります。
// Node.js(ブラウザでのアウトプットは異なります)
// 文字列・数値の派生元はオブジェクト
Object.getPrototypeOf("abc")
> {}
Object.getPrototypeOf(123)
> {}
// オブジェクトの派生元はnull
Object.getPrototypeOf({})
> [Object: null prototype] {}
よく使う下記のようなコードは null
というリテラルを代入する事を意味しています。
const x = null;
リテラルとは、データ型を一意に特定することができる値の表現です。文字列リテラル、数値リテラルが分かりやすいのではないかと思います。
// 文字列リテラル
const s1 = "abc";
const s2 = '文字列';
// 数値リテラル
const n1 = 123456;
const n2 = 123_4567;
リテラルとグローバル変数がいまいち分からない場合は下記サイトのコラムを参考にしてみてください。
コラム undefinedはリテラルではない | JavaScriptPrimer
「宣言していない」「undefined の代入」は等価ではない
JavaScript では値を代入する前の変数や、何も返さない関数の戻り値を参照すると undefined
が得られます。
let x;
x; // undefined
function fn() {}
fn(); // undefined
上記のケースにおいて undefined
は JavaScript が「値が何も設定されていない」事を示すためのものです。開発者が初期化のために undefined
を代入しても等価にはなりません。
const user1 = {
name: "カルテット太郎",
};
const user2 = {
name: "カルテット花子",
nickname: "hanako",
};
user2.nickname = undefined;
Object.keys(user1); // ["name"]
Object.keys(user2); // ["name", "nickname"]
"nickname" in user1; // false
"nickname" in user2; // true
for (const i in user1) { console.log(i) }; // "name"
for (const i in user2) { console.log(i) }; // "name" "nickname"
「宣言していない」状態に戻すには delete
演算子を利用します。
delete user2.nickname;
Object.keys(user2); // ["name"]
"nickname" in user2; // false
for (const i in user2) { console.log(i) }; // "name"
このように「宣言前の空の値が undefined
、宣言後の空の値が null
」と区別するのは誤った理解と言えます。
厳密等価と等価
JavaScript には明示的な型宣言はありませんが、内部では「データ型」というものがあります。
厳密等価(===
)はデータ型を変換せず一致を検査します。
"" === 0 // false
"0" === 0 // false
null === undefined // false
undefined === 0 // false
null === 0 // false
等価(==
)はデータ型の変換後に一致を検査します。
"" == 0 // true
"0" == 0 // true
null == undefined // true
undefined == 0 // false
null == 0 // false
ESLint に等価演算子を禁止するためのルールがあるように、特に理由がなければ厳密等価を利用したほうがコードの可読性が上がるでしょう。
function something(n) {
// n が "000" の時どのような結果になるのか曖昧
// そもそもこの関数は文字列を許容するのか?
return n == 0;
}
function something(n) {
// 0 以外は全て false 判定になる事が明確
return n === 0;
}
Disallow Null Comparisons (no-eq-null) | ESLint
厳密等価と等価の例から分かるように、冒頭の if 文は undefined
のみを想定したガードです。
// x の値が null, 0, "" の時はガードの対象外
if (x === undefined) {
return;
}
DOM によって意図せず変化する undefined
ここからは x === undefined
でキャッチできないバリデーションについての説明です。まず、シンプルな HTML のフォームを例とします。
<form>
<input type="text">
<button type="submit">submit</button>
</form>
input
に何も入力せずに submit
ボタンをクリックした場合、下記のような undefined
によるバリデーションは意味をなしません。なぜなら input.value
は文字列で、何も入力しない場合に得られる値は空文字列の ""
だからです。
document.querySelector('form').addEventListener("submit", (event) => {
const input = document.querySelector('input');
// このバリデーションで未入力を検出する事はできない
if (input.value === undefined) {
window.alert("入力されていません");
event.preventDefault();
}
});
また何らかのフラグによって DOM を出し分けている場合、存在しない要素に対して querySelector
は null
を返却します。そのため undefined
によるバリデーションは同じく意味をなしません。
if (xxx === true) {
document.querySelector('form').appendChild(
document.createElement("input")
);
}
document.querySelector('form').addEventListener("submit", (event) => {
const input = document.querySelector('input');
// input は null のため、このバリデーションで submit を中断する事はできない
if (input === undefined) {
window.alert("入力されていません");
event.preventDefault();
}
});
さらに input
に初期値として undefined
を与えた場合 "undefined"
という文字列が画面に表示されてしまいます。これを嫌って開発者が null
を与えているかもしれません。
// inputに "undefined" という文字列が現れる
const initialValue = undefined;
document.querySelector("input").value = initialValue;
// inputに "" という文字列が現れる
const initialValue = null;
document.querySelector("input").value = initialValue;
上記のようなコードを変数に保存して使い回す場合、初期値として与えた undefined
が null
や ""
に変化しているかもしれません。
// データの初期化
let data = {
inputValue: undefined;
};
// 画面にレンダリング
render(data);
// ユーザーが入力した値を取得
data = getUserInputData();
if (data.inputValue === undefined) {
throw new Error("入力してください");
}
// 保存
persistUser(data);
コードによって意図せず変化する undefined
平均値を算出するコードを例とします。
function calcAverage(...array) {
return array.reduce((a, b) => a + b, 0) / array.length;
}
calcAverage(30, 35, 45, 26); // 34
数値の列挙には undefined
を想定するものとします。この場合 NaN
が返却されるため isNaN
によるバリデーションを追加しました。
const result = calcAverage(30, 35, 45, 26, undefined); // NaN
if (isNaN(result)) {
throw new Error("意図しないパラメータが与えられました");
}
ところが今度は数値の列挙に何らかの理由で null
が紛れ込みました。 null
は数値の 0
とみなされるため、計算結果は NaN
にはなりません。計算結果は数値で返却されるため、何も問題が起こっていないかのように計算処理は続行します。
const result = calcAverage(30, 35, 45, 26, null); // 27.2
// このバリデーションではエラーを検出できない
if (isNaN(result)) {
throw new Error("意図しないパラメータが与えられました");
}
「何らかの理由」というのがイメージしづらいかもしれませんが null
は API レスポンスや開発者による初期化処理など、さまざまな箇所で発生します。
// ==============================
// API レスポンスの仕様の取り決め
// ==============================
[User Response] {
id: number
name: string
age: number (optional)
}
- `GET /users` [User Response] のコレクションを返す
- `GET /users/:id` [User Response] を単体のデータとして返す
バックエンドが Node.js で開発されているようなケースを除き、API の開発者はオプショナルの二通りの表現方法「項目そのものがない事」または「項目は存在し値が null である事」によって JavaScript にどんな影響を与えるのか知り得ないでしょう。リリース当初 undefined
だったレスポンスの値が、バックエンドのリファクタやライブラリの差し替えによって null
に変わるかもしれません。もし null
が与えられたらどう振る舞うのか、想定しておいても損はしないはずです。
fetch(`/users/${id}`).then(response => {
const json = response.json();
return {
id: Number(json.id),
name: json.name,
age: json.age === undefined ? 0 : json.age,
};
});
// リリース当初は user.age は undefined だったため、0 で初期化されていた
// いつの間にか user.age は null に変わり、0 で初期化されなくなった
面倒だから全部 null で初期化しちゃえば?
undefined
が意図せず null
に変化するのなら、最初から全部 null
で初期化すれば解決するのかと言うと、そうでもありません。例えば Array.find
は該当の要素が見つからない場合に undefined
を返却します。
let user = null;
function findUser(id) {
[
{ id: 1, name: "a" },
{ id: 2, name: "b" },
].find(user => user.id === id);
}
user = findUser(1000); // undefined
// このバリデーションは意味をなさない
if (user === null) {
throw new Error("ユーザーが見つかりません");
}
TypeScript のような型のある言語を使えば、フロントエンドのコード内のミスを防ぐ事はできるでしょう。
interface User {
id: number;
name: string;
optionalValue?: number;
nullableValue: number | null;
}
const user1: User = {
id: 1,
name: "a",
// 型定義に違反する値は代入できない
optionalValue: null, // [ERROR] optional に null は代入できない
nullableValue: undefined, // [ERROR] nullable に undefined は代入できない
};
ただし型アサーション( as
によるキャスト)や API レスポンスなど、ランタイム(TypeScript から JavaScript にトランスパイルした後のコード)で代入される値については防ぎようがありません。
// DOM を経由した値など as でキャストする例
const onChangeEventHandler = (target) => {
const value = target as string;
}
// 実際には HTMLElement が代入されたとしてもコンパイルエラーは発生しない
console.log(value); // HTMLInputElement {...}
// API レスポンスの値をそのまま使用する例
function parse(response): { id: number, value: number } {
return {
id: response.id,
value: response.value
};
}
// 期待する number でなく string が代入されたとしてもコンパイルエラーは発生しない
console.log(response); // { id: "0001", value: "1,000円" }
つまり undefined
または null
どちらに統一しようが、代入される値を束縛する事はできません。厳密等価による判定は、局所的なガードになってしまうのです。
function getUser(id) {}
function parse(user) {}
function render(user) {}
const user = parse(getUser(1));
render(user).then(result => {
// DOM を経由した場合、空文字列かもしれない
if (result === "") {}
// Array.find を経由した場合、undefined かもしれない
if (result === undefined) {}
// 初期化ロジックを経由した場合、null かもしれない
if (result === null) {}
});
結局どうして欲しいのか
ここからは、抜け漏れをできるだけ防ぐために気をつけるポイントを紹介します。TypeScript のコードを紹介していますが、それぞれのポイントで言いたい事は JavaScript でも同じです。
宣言する型の範囲はできるだけ狭くする
開発者が自分自身のコードに課す型宣言は、できるだけ範囲を狭くしてください。
// Optional によって null でなく undefined を扱う事を宣言
interface Params {
url: string;
queryStrings?: { [key:string]: string }[]; // undefined を使う事を宣言
}
function request(params: Params) {}
request({
url: "http://example.com",
queryStrings: null, // [ERROR] 誤った null の代入
);
// NG
// undefined でも null でも何でも代入できてしまう曖昧さ
interface Params {
url: string;
queryStrings: any;
}
// NG
// Optional を使い過ぎると何が必要なのか分からなくなる
interface Params {
id?: string;
url?: string;
queryStrings?: object;
}
受け取る値の範囲はできるだけ広くする
ランタイムで値が代入される箇所、または複数のロジックを経由して到達した箇所では、できるだけ広い範囲の値を扱えるようにしてください。
// undefined / null / 空文字列 どれを受け取っても同じ結果になる
// (0 も対象になるため数値の扱いには気をつける事)
function something(value: string | null) {
if (!value) {
throw new Error("パラメータが不正です");
}
}
// undefined / null を等価に扱う例
interface Param {
foo?: {
bar: { value: number } | null
}
}
function something(param: Param): number {
// Optional chaining(?.)
// undefined / null どちらでもオブジェクトのプロパティを辿れる
let x = param.foo?.bar?.value;
// Logical nullish assignment(??=)
// undefined / null いずれかであれば右辺の値を代入する
x ??= 1;
return x + 100;
}
// NG
// undefined / null が考慮されていない
function something(value: string) {
if (value === "") {
throw new Error("パラメータが不正です");
}
}
暗黙の型を避ける
暗黙の型を返却せず、どのような型を返却するかを明確に宣言してください。
interface User {
id: number;
address: string | null;
}
// undefined を null に変換するコードが強制される
function pickupAddress(user: User, users: User[]): string | null {
return (users.find(user => user.id)?.address) || null;
}
// NG
// 戻り値の型を宣言していない
// string | null | undefined と推論される
function pickupAddress(user: User, users: User[]) {
return users.find(user => user.id)?.address;
}
// 型宣言が面倒になるので後続の処理が any や Optional になりがち
const address = pickupAddress();
終わりに
undefined
null
をきちんと区別したコードを書くべきとする持論もあるでしょう。
経験に基づく主張ですが、どのような場面で変数の値が undefined
null
に変化するのか熱心に気を配っていても、想定外の値は混入します。そして、それが原因で想定外のエラーが発生した場合、不利益を被るのはシステムを利用しているユーザーです。デバッグすると想定外に何故か現れる null
値、そしてコードを追いかけると見つける if (x === undefined)
によるバリデーション。システムリリース後の不具合報告で、何回もこのパターンに出くわした事があります。
undefined
や null
がそれぞれ「未登録」や「ユーザーによる値の消去」など意味を持つのであれば話は違いますが、ほとんどのケースにおいて undefined
と null
は「空」という等価な意味合いではないでしょうか?
x === undefined の比較をしないでください。
これは JavaScript を知り尽くしたような顔をしてミスを指摘しているのではなく、提案としてのレビューコメントです。想定外に紛れ込む null
を考慮しておいたほうがメリットが大きいと思うのですが、いかがでしょうか?どうか、ご検討いただければと思います。