はじめに
こんにちは!
Habitat Hubのボードゲーム好きエンジニア @tom-takeruです。
JavaScriptやTypeScriptを使っている皆さんにとってお馴染みのlet
とconst
の使い分けに焦点を当てた記事をお届けします。
ちなみに、私の詳しいプロフィールはtom-takeru Webでチェックしてみてください。
さて、let
ではなくconst
を使うことのメリットって知っていますか?実は、この小さな選択がコードの読みやすさや安定性に大きく影響するんです。でも、どうやってlet
からconst
に変えるのがベストなのか、そんな疑問に答えるためにこの記事を書きました。
「え、そんなに大事なの?」と思うかもしれませんが、この記事を最後まで読むと、const
を使うことの大切さがきっと伝わると思います!
これはなに
「JavaScriptやTypeScriptの変数宣言では可能な限りconst
を使おう!」という思考を広めるための記事です。
対象読者
-
let
とconst
、どっちを使ったらいいの?という人 -
let
をconst
にリファクタリングしたいけど方法がわからない!という人 -
const
の方が良さそうと感覚ではわかるけどうまく説明できない人
前提
const
- ブロックスコープの変数を宣言します。
- 値の再代入はできません。
- ただし、オブジェクトや配列のプロパティや要素は再代入可能です。
const x = 1;
x = 2; // Error!!!
let
- ブロックスコープの変数を宣言します。
- 値の再代入が可能です。
let x = 1;
x = 2; // OK!!!
ゴール
-
const
をデフォルトの変数宣言として捉えられている状態であること -
const
を利用すべき状況を理解している状態であること -
let
->const
のリファクタリングができる状態であること
本題
- なぜ
let
ではなくconst
をできるだけ使うべきなのか? -
let
->const
のリファクタリング例
をそれぞれ解説します。
なぜlet
ではなくconst
をできるだけ使うべきなのか?
結論は、
const
を使うと、より堅牢性・可読性が高くなるから
です。
以下で詳しく説明します。
堅牢性が高くなるから
const
を利用することで、変数の値がプログラムの実行中に変更されないことが保証されます。これにより、誤って変数の値を変更するバグを防ぐことができます。
👿宣言した変数の値が意図せず変更される。
let userRole = 'guest'; // userRoleは変更される想定なし
console.log(userRole); // => guest
// userRole変更しーちゃお!
userRole = 'admin';
console.log(userRole); // => admin
🥳宣言した変数への再代入でエラーになる。
const userRole = 'guest';
console.log(userRole); // => guest
userRole = 'admin'; // Error!!!
// エラーが出たってことはuserRoleは変更できないっていうことか!
可読性が高くなるから
const
を利用すると、その変数の値が再代入されないことが明確になります。
これは、他の開発者がコードを読む際に、変数の役割をより容易に理解できるようにします。
👿例1:宣言した変数の役割が拡大してしまっている。
let userNames = ['Alice', 'Bob', 'Charlie'];
userNames = userNames.map(name => name.toUpperCase());
console.log(userNames); // => ['ALICE', 'BOB', 'CHARLIE']
🥳例1:宣言した変数の役割が明確に分けられている。
const userNames = ['Alice', 'Bob', 'Charlie'];
const upperCaseUserNames = userNames.map(name => name.toUpperCase());
console.log(upperCaseUserNames); // => ['ALICE', 'BOB', 'CHARLIE']
👿例2:宣言した変数の意味がわからなくなる。
let userNames = ['Alice', 'Bob', 'Charlie'];
// ...
// 長ーいコード
// ...
// 再代入!
userNames = userNames.map(name => name.toUpperCase());
// ...
// 長ーいコード
// ...
// 再代入部分を読んでいないとなんで???ってなってしまう
console.log(userNames); // => ['ALICE', 'BOB', 'CHARLIE']
🥳例2:宣言した変数の意味を正しく認識できる。
const userNames = ['Alice', 'Bob', 'Charlie'];
// ...
// 長ーいコード
// ...
// 別の変数で定義!
const upperCaseUserNames = userNames.map(name => name.toUpperCase());
// ...
// 長ーいコード
// ...
// どちらも予想通り
console.log(userNames); // => ['Alice', 'Bob', 'Charlie']
console.log(upperCaseUserNames); // => ['ALICE', 'BOB', 'CHARLIE']
const
により変数を宣言することで、宣言した変数そのものへの再代入は防ぎます。
ですがその変数の中身がオブジェクトや配列の場合、そのプロパティや要素へは再代入可能となってしまいます。
// 例:constで宣言したオブジェクトのプロパティの内容変更
const person = {
name: 'Tom',
age: 40,
};
console.log(person); // => { name: 'Tom', age: 40 }
person.name = 'Michael' // OK!!!
person.age = 20 // OK!!!
console.log(person); // => { name: 'Michael', age: 20 }
// 例:constで宣言した配列の要素の内容変更
const numbers = [0, 2, 3];
console.log(numbers); // => [0, 2, 3]
numbers.push(4); // OK!!!
numbers[0] = 1; // OK!!!
console.log(numbers); // => [1, 2, 3, 4]
let
->const
のリファクタリング例
変数への再代入がなければconst
を使い、不変性を保証します。再代入がある場合、特に変数の意味が変わるときは新しい変数を定義し、条件分岐やループ内では三項演算子や関数化、.forEach
メソッドを利用します。これにより、コードの可読性と安全性が向上します。
具体的な実装例を以下に示します。
その変数への再代入がない場合
単純にその変数への再代入がない場合
👿リファクタリング前
let x = 1;
console.log(x); // => 1
🥳リファクタリング後
const x = 1;
console.log(x); // => 1
その変数に格納されたオブジェクトのプロパティへの再代入しかない場合
👿リファクタリング前
let person = {
name: 'Tom',
age: 40,
};
person.name = 'Michael'
console.log(person); // => { name: 'Michael', age: 40 }
🥳リファクタリング後
const person = {
name: 'Tom',
age: 40,
};
person.name = 'Michael'
console.log(person); // => { name: 'Michael', age: 40 }
その変数に格納された配列の要素への再代入しかない場合
👿リファクタリング前
let numbers = [0, 2, 3];
numbers.push(4);
numbers[0] = 1;
console.log(numbers); // => [1, 2, 3, 4]
🥳リファクタリング後
const numbers = [0, 2, 3];
numbers.push(4);
numbers[0] = 1;
console.log(numbers); // => [1, 2, 3, 4]
その変数への再代入がある場合
再代入の前後で変数の意味合いが変化してしまっている場合
(これがこの記事で一番伝えたい事例!)
👿リファクタリング前
let userNames = ['Alice', 'Bob', 'Charlie'];
userNames = userNames.map(name => name.toUpperCase());
console.log(userNames); // => ['ALICE', 'BOB', 'CHARLIE']
🥳リファクタリング後
適切な名前の別変数を宣言して代入する。
const userNames = ['Alice', 'Bob', 'Charlie'];
const upperCaseUserNames = userNames.map(name => name.toUpperCase());
console.log(upperCaseUserNames); // => ['ALICE', 'BOB', 'CHARLIE']
変数の値が条件に基づいて決まる場合
👿リファクタリング前
let statusMessage;
if (user.isLoggedIn()) {
statusMessage = 'User is logged in.';
} else {
statusMessage = 'User is not logged in.';
}
🥳リファクタリング後
三項演算子を使う。
const statusMessage = user.isLoggedIn() ? 'User is logged in.' : 'User is not logged in.'
変数の値が変数に基づいて決まる場合2
👿リファクタリング前
let value;
const condition = "a";
switch (condition) {
case "a":
value = "Apple";
break;
case "b":
value = "Banana";
break;
default:
value = "Unknown";
}
console.log(value); // => Apple
🥳リファクタリング後
関数化する。
const getValue = (condition) => {
switch (condition) {
case "a":
return "Apple";
case "b":
return "Banana";
default:
return "Unknown";
}
}
const condition = "a";
const value = getValue(condition);
console.log(value); // => Apple
後で値が決まる変数の場合
👿リファクタリング前
let result;
try {
result = performComplexCalculation();
} catch (error) {
result = "NaN";
console.error(error);
}
console.log(result);
// その後の処理
🥳リファクタリング後1
awaitを使う。
const result = await performComplexCalculation().catch(error => {
console.log(error);
return "NaN"
});
🥳リファクタリング後2
ブロック内でconstを用いて宣言する。
try {
const result = performComplexCalculation();
console.log(result);
// resultの値を参照する処理
} catch (error) {
console.error(error);
// エラー時の処理
} finally {
// 成功可否に関わらないその後の処理
}
ブロック内でconst
で宣言するリファクタリング方法では、result
のスコープがtry
ブロック内に限定されるため、try
ブロックが大きくなる可能性があります。
そのためそのような状況では、let
を使った方が可読性を高くできる場合があります。
ループ処理の場合
これはどちらかというと、
「不要なインデックスはバグの温床でしかないから使うべからず」
という話です!
👿リファクタリング前
const userNames = ['Alice', 'Bob', 'Charlie'];
for(let i = 0; i < userNames.length; i++) {
console.log(userNames[i]);
}
// => Alice
// => Bob
// => Charlie
🥳リファクタリング後
const userNames = ['Alice', 'Bob', 'Charlie'];
userNames.forEach(userName => {
console.log(userName);
});
// => Alice
// => Bob
// => Charlie
この例ではあくまでlet
を使わないようにリファクタリングしているだけで、userName
がconst
(再代入不可)になっているわけではありません。
const userNames = ['Alice', 'Bob', 'Charlie'];
userNames.forEach(userName => {
userName = 'Tom';
console.log(userName);
});
// => Tom
// => Tom
// => Tom
番外編
プロパティや要素への再代入禁止
プロパティや要素への再代入を禁止する方法をいくつかご紹介します!
Object.seal()
Object.seal()
メソッドを使用すると、オブジェクトに対して新しいプロパティの追加を防ぎ、既存のプロパティの削除を不可能にします。しかし、既存のプロパティの値の変更は可能です。これにより、オブジェクトの構造を一定に保ちつつ、プロパティの値は動的に変更できるようになります。
const person = Object.seal({ name: 'Tom', age: 20 });
person.name = 'Michael'; // 値の変更は可能
person.height = 180; // 新しいプロパティの追加はできない
delete person.age; // プロパティの削除は不可能
console.log(person); // => { name: 'Michael', age: 20 }
Object.seal()
によって封印されたオブジェクトは、そのプロパティの構成を変更することはできませんが、プロパティの値自体の更新は許可されます。プロパティを追加や削除しようとすると、操作は無視されます(厳格モードではエラーが発生します)。
Object.freeze()
Object.freeze()
メソッドは、オブジェクトを不変にします。これは、オブジェクトのプロパティの追加、削除、変更を防ぎ、プロパティの値も変更できなくなることを意味します。Object.freeze()
で凍結されたオブジェクトは、その構造や内容を完全に固定し、どのような変更も許可しません。
オブジェクトの場合
const person = Object.freeze({ name: 'Tom', age: 20 });
person.name = 'Michael'; // 変更できない(エラーにはならない)
person.height = 180; // 新しいプロパティの追加も不可能
delete person.age; // プロパティの削除も不可能
console.log(person); // => { name: 'Tom', age: 20 }
配列の場合
const numbers = Object.freeze([1, 2, 3]);
numbers[0] = 4; // 要素の変更も不可能(エラーにはならない)
numbers.push(5); // 要素の追加も不可能
console.log(numbers); // => [1, 2, 3]
Object.freeze()
で凍結されたオブジェクトや配列は、その後どのような変更も受け付けません。これにより、不変のデータ構造を保証することができ、プログラムの予測可能性や安全性を高めることができます。
- 一度凍結すると解凍はできません。
- Object.freeze()はパフォーマンスに影響を与える可能性があるため、利用する際には注意が必要です。
- Object.freeze()は浅い凍結(Shallow Freeze)であるため、ネストされたオブジェクトや配列には適用されません。
- 完全な普遍性を実現するためには、深い凍結(Deep Freeze)のための関数を実装してあげる必要があります。
const deepFreeze = (obj) => {
Object.freeze(obj);
// Object.getOwnPropertyNames を利用して、オブジェクトのプロパティと配列の要素を処理
Object.getOwnPropertyNames(obj).forEach(prop => {
if (typeof obj[prop] === 'object' && obj[prop] !== null && !Object.isFrozen(obj[prop])) {
deepFreeze(obj[prop]);
}
});
return obj;
}
// オブジェクトの利用例
const myObj = { a: 1, b: { c: 2, d: { e: 3 } }};
deepFreeze(myObj);
myObj.a = 2; // 変更できない(エラーにはならない)
myObj.b.c = 3; // 変更できない(エラーにはならない)
myObj.b.d.e = 4; // 変更できない(エラーにはならない)
console.log(myObj); // => { a: 1, b: { c: 2, d: { e: 3 } }}
// 配列の利用例
const myArray = [1, [2, 3], { a: 4, b: 5 }];
deepFreeze(myArray);
myArray[0] = 10; // 変更できない(エラーにはならない)
myArray[1][0] = 20; // 変更できない(エラーにはならない)
myArray[2].a = 30; // 変更できない(エラーにはならない)
console.log(myArray); // => [1, [2, 3], { a: 4, b: 5 }]
Object.defineProperty()
オブジェクトの場合
const person = {
name: 'Tom',
age: 40,
};
// person オブジェクトの 'name' プロパティを読取専用に設定
Object.defineProperty(person, 'name', { writable: false });
person.name = 'Jerry'; // 変更できない(エラーにはならない)
console.log(person.name); // => Tom
配列の場合
const userNames = ['Alice', 'Bob', 'Charlie'];
// userNames 配列の 0 番目の要素('Alice')を読取専用に設定
Object.defineProperty(userNames, 0, { writable: false });
userNames[0] = 'Dave'; // 変更できない(エラーにはならない)
console.log(userNames[0]); // => Alice
TypeScriptのreadonly
TypeScriptでは、readonly
キーワードはクラス構文におけるプロパティを変更不可にするために利用されます。
class Book {
readonly title: string;
readonly author: string;
constructor(title: string, author: string) {
this.title = title;
this.author = author;
}
}
// インターフェースでreadonlyを利用
interface User {
readonly id: number;
name: string;
}
const user: User = { id: 123, name: "Alice" };
user.id = 456; // TypeScriptはここでコンパイル時エラーを投げます
// 型エイリアスでreadonlyを利用
type Point = {
readonly x: number;
readonly y: number;
};
const point: Point = { x: 10, y: 20 };
point.x = 30; // TypeScriptはここでコンパイル時エラーを投げます
さいごに
個人的には、「const
だとエラーになるな。→ const
で実装できるようにリファクタリングして可読性あげよう!→→→ぐぬぬ。 let
使うか、、、。」という思考順です。
コードを読む時も、「let
を使っている=仕方なく再代入を許容した」と読み取り、再代入の可能性を意識して少し身構えて読みます。
いかがだったでしょうか?
この記事がlet
とconst
の使い分けについての理解を深める助けとなったことを願います。
もし間違いやご指摘がありましたら、コメントでお知らせください
よかったら私の所属する開発チーム Habitat Hub のフォローもよろしくお願いします!
定期的にメンバーがためになる記事を発信しています!
参考