2025.05.16
今回はWebフロントエンドをTypeScriptで実装する際に誰もが一度はモヤっとするであろうポイント、 nullとundefinedをどう扱うべきか について書きます。
私の宗教観がだいぶ強く入った内容なので、Webフロント開発 × TypeScriptのベストプラクティスではなく「まあそういう考え方もあるかあ」的な感覚で捉えていただきたいと最初に 保険をかけて お断りしておきます。
null / undefined は明確に分けて扱うべき
いきなり章題に結論を書いてしまいましたが、私は「この2つは明確に意味が異なるので分けて扱うべき」と考えます。
使い分けとしては
- 空の状態を取る可能性があるものはnull
- 存在しない可能性があるものはundefined
とします。
例えば <input type="text" />
のvalue属性値がセットされる変数 hoge
について考えるとき、この要素自体が常に存在しているのであれば、変数定義は const hoge = string | null
です。しかし何らかの条件によって項目自体が存在しない可能性がある場合、変数の型は const hoge = string | null | undefined
とします。
こうする理由は正直なところ「わざわざ静的型言語を使っているのに型を曖昧に扱うのが気持ち悪い」という感情的なものが割と大きいです。しかしそれだとコーディングコストが増えるだけでこれ以上読む必要のないポストになってしまうので、現実的なメリットも上げておきます。
都度の判断を不要にできる
nullとundefinedの差異を意識したいケースというのがあります。
例としてユーザーデータ更新APIへ連携するリクエストボディのオブジェクト型を考えましょう。「ユーザーはnameとaddressを持ち、addressはnullableである」というデータ要件において、適切な型定義は次のうちどれでしょうか?
interface User1 {
name: string
address?: string
}
interface User2 {
name: string
address: string | null
}
interface User3 {
name: string
address?: string | null
}
正解はUser3です。
User1ではaddressにnullをセットしようとしたタイミングでTSエラーが発生しますし、User2では「addressは今の状態のまま変更したくない」というケースにおいて、「今と同じaddressをaddressプロパティにセットする」という処理が必要になってしまうからです。
User3であればaddressをundefinedにでき、「undefinedなプロパティはAPIリクエストに乗せない」という共通処理を実装しておけばOKです(JSON.stringify
もそういう挙動ですね)。
人によっては「User2でもよくね?」と思うかもしれませんが、「今と同じaddressをaddressプロパティにセットする」という処理は「今と同じaddressでaddressを更新したい」という意図を持つことと同義です。当然そんな意図があるはずもなく、意図しないことは実装すべきではありません。
このようなnullとundefinedの差異を意識したい(意識すべき)ケースと、そうでないケースを都度都度ルール決めするのはだいぶ面倒です。であれば常に明確に分けて扱った方が精神的にラクになれます。
値が空なのか、そもそも存在しないのかを区別できる
1つ目のメリットの逆説ではありますが、nullとundefinedに上記の意味づけをすることで「値が空であること」と「値の元となる何かしらが存在すらしないこと」を区別できるようになります。
上述した <input type="text" />
のように、フロントエンドのプログラミングでは入力要素とその入力要素の値をバインドするオブジェクトが対になることが多く、さらに入力要素は様々な条件分岐によっては存在したりしなかったりします(登録フォームと更新フォームで表示する項目が若干異なるとか)。
nullとundefinedを明確に分けてあつかうことで、「要素が存在しないケース」と「存在するが未入力のケース」で処理を分たいときに、わざわざ別で状態を持つ必要がなくなります。逆に明確に分けて扱うルールがない開発プロジェクトだと、実装者によって別で状態を持ったりundefinedに意味を持たせたりしてしまうので、 if(!hoge)
のような曖昧検索で意図しない挙動をするようなバグを作り込みやすくなります。
現実的なアプローチ
しかしnullとundefinedを常に分けて扱うルールを敷く場合、それはそれで現実的に考えなくてはいけないことが結構あります。
ここからは私が実際にこのルールを元に実装をする際に得たナレッジを少しシェアします。
尚、ここから先のサンプルコードは特定のフレームワークやライブラリの知見を不要にするため、かなり抽象的な書きっぷりになっています。ご了承ください。
入力フォームが取りうる状態を表現する型と、その入力フォームがサブミット可能な状態になったときの型は別々に定義する
例えばこんな感じのユーザーデータを登録 or 更新する、こんな感じの入力フォームがあるとします。
<form>
<label for="name">名前</label>
<input type="text" id="name" />
<br />
<label for="name-ruby">名前かな</label>
<input type="text" id="name-ruby" />
<br />
<label for="name">住所</label>
<input type="text" id="address" />
</form>
このフォームは「名前」と「名前かな」が入力必須、「住所」が任意入力とします。
そしてこのフォームの値がバインディングされるオブジェクトの型を、以下のように定義することがが多いと思います。
interface User {
name: string
nameRuby: string
address: string | null
}
一見問題のない定義に見えますが、各入力要素を未入力状態にすると困った事態が発生します。
まず大前提として、「入力要素が未入力のとき、バインドされる値は空文字列ではなくnullになる」ようにしておかねばなりません。「空の状態を取る可能性があるものはnull」というルールなので、「空の状態 = null」以外の状態を持ち出すと「空」に対する処理分岐が複雑になるからです。それ自体はReactでもVueでも何でも、そういう挙動をとるようにinput要素をラップしたカスタムコンポーネントを作ればOKです。
しかしこの前提を踏まえて考えると、上記の型定義は不十分です。例えばユーザーの登録を行う時、バインディング変数 user
には以下のような初期値を与える必要があります。
const user = {
name: null
nameRuby: null
address: null
}
// User型を受け取るフォームコンポーネント生成処理。
const form = createForm(user)
しかしnameとnameRubyは型定義上nullを許容していないため、フォームコンポーネントへの引渡時にTSエラーが発生します。これを防ぐためにnameとnameRubyの型定義を緩めようとすると
interface User {
name: string | null
nameRuby: string | null
address: string | null
}
こんな感じになります。
これでTSエラーは解消しますが、今度はフォームコンポーネントがサブミット可能な状態( = バリデーションエラーがない状態)になったとき、別の問題が起きます。
サブミットしたいのでバインディング変数をAPIリクエスト処理へ引き渡すことになりますが、APIリクエスト処理としてはnameとnameRubyを当然non nullableなものとして考えているはずです。IFとしては
interface CreateParams {
name: string
nameRuby: string
address: string | null
}
const request = (params: CreateParams) => {}
こんな感じですね。
このrequestメソッドにバインディング変数をそのまま渡すと、「nullは許容できない」というTSエラーが発生してしまいます。なので
form.onSubmit = () => {
request({
name: user.name!,
nameRuby: user.nameRuby!,
address: user.address
})
}
のように、わざわざnameとnameRubyが必ず存在するオブジェクトに変換してからrequestメソッドに渡す必要があります。
この変換自体も面倒ですが、非nullアサーションの多用は思わぬバグの温床になりがちなので、なるべく避けたいところです。
これらの問題を解決するため、入力フォームコンポーネントの型定義は、入力中に取りうる状態とサブミット可能になったときの状態を別々に型定義することをお勧めします。そしてフォームコンポーネントはサブミットイベントを発火するときに、サブミット可能な状態となった型のオブジェクトを親コンポーネントに渡します。以下のような形です。
// フォームコンポーネント
// サブミット可能な状態の型定義
interface FixedValues {
name: string
nameRuby: string
address: string | null
}
// 入力中に取りうる状態の型定義
interface InputValues
name: string | null
nameRuby: string | null
address: string | null
}
// 親コンポーネント
// 初期値はInputValuesを満たしているので問題なし
const user = {
name: null
nameRuby: null
address: null
}
// User型を受け取るフォームコンポーネント生成処理。
// フォームコンポーネントはInputValues型のオブジェクトを受け取る。
const form = createForm(user)
// (中略)
form.onSubmit = (fixedValues: FixedValues) => {
// バインドしたuser自体ではなく、フォームコンポーネントが渡してきた値をAPIリクエスト処理に渡す。
request(fixedValues)
}
曖昧判定はLintで撲滅する
上の方でも少し触れましたが、せっかくnullとundefined(ついでに空文字列も)を明確に分けて扱うのであれば、 if (!hoge)
のような曖昧判定は避けるべきです。
私はESLintのstrict-boolean-expressionsで制限をかけています。ルール設定はこんな感じにすることが多いです。
"@typescript-eslint/strict-boolean-expressions": [
"error",
{
allowString: false,
allowNullableString: false,
allowNumber: false,
allowNullableNumber: false,
allowNullableBoolean: true,
allowAny: false,
allowNullableObject: false
}
]
結果的にnullもundefinedも空文字列も許容される場合はもちろんあって、 if (hoge !== null && hoge !== undefined && hoge !== "")
みたいな冗長記述になることもなくはないのですが。
nullとundefinedを分けない方が良いと考える意見も勿論ある
勿論あるというか、TypeScriptのバイブル的存在であるサバイバルTypeScriptには「使い分け意識を育てる労力は、それに見合うメリットがない」として、割と否定的です。
詳細は上記URLから直接確認していただきたいのですが、サバイバルTypeScriptに書かれていることは至極真っ当です。
冒頭にも書いた通りここにツラツラ書き連ねてきたのは私の個人的な考えなので、それぞれの考えを頭の片隅におきながら、皆さんも独自のルールとそれに伴った実装を楽しんでみてください。