27
9

More than 1 year has passed since last update.

null か undefined か、どちらを使うかが問題だ

Last updated at Posted at 2021-12-04

API を設計する、ドキュメント DB のデータ構造を定義する、JavaScript/TypeScript の内部的な型を定義する、というような場合、JSON で型設計を行うことになります。その際、避けて通れないのが、値が存在しないかも知れないプロパティの扱いに関する設計方針の検討です。Haskell についても少しだけ言及します。

以下写真は 0、nullundefined のとてもわかり易い概念です。

0 vs null vs undefined
0 vs null vs undefined より引用。

TL;DR

  • 実際は null でも undefined でもそれほど大差はない
  • JavaScript/TypeScript では == null!= nullnullundefined をまとめて判定したい
  • Haskell では null で扱うのが楽
  • どちらでも良いが、システム、アプリで null または undefined を統一する

JSON

例えば会員情報として以下を扱う JSON の型定義をしてみます。

  • ID
  • 名前
  • 年齢(オプション)

年齢がオプションなので、未設定の場合にどのような表現とするかを考えなければなりません。

パターン 1: 年齢プロパティを省略

{
  "id": 42,
  "name": "Marty McFly"
}

パターン 2: 年齢プロパティに null 指定

{
  "id": 43,
  "name": "Biff Tannen",
  "age": null
}

パターン 3: 年齢プロパティに undefined 指定

{
  "id": 44,
  "name": "Emmett Brown",
  "age": undefined
}

このうちパターン 3 は、JSON の値として認められていない undefined を使用しているため、JSON としてはありえません。つまり JSON の表現としてはパターン 1 のプロパティを省略することで値が存在しないことを表すか、パターン 2 の null で値が存在しないことを表現するかのいずれかになります。

JavaScript

JSON を CSV に変換する JavaScript コードを書いてみます。

csv.js
const users = [
  {
    "id": 42,
    "name": "Marty McFly"
  },
  {
    "id": 43,
    "name": "Biff Tannen",
    "age": null
  }
]
const csv = users.reduce((acc, cur) => {
  const record = Object.keys(cur).map(key => JSON.stringify(cur[key])).join(',')
  return `${acc}${record}\n`
}, `${Object.keys(users[0]).join(',')}\n`)

console.log(csv)

このコードの実行結果は以下のとおりです。

$ node csv.js
id,name
42,"Marty McFly"
43,"Biff Tannen",null
$

出力した CSV に必要なヘッダが含まれていません。これはリストのオブジェクトの形式が一致していないために起こる不具合です。JavaScript は型に関してプログラミング言語としてプログラマを何もサポートしてくれないためしばしばこうしたことが起こり得ます。気をつけるしかありません。

TypeScript

TypeScript なら型を付けられるので、JavaScript のような不幸は発生しません。

パターン 1: 年齢プロパティを省略可能

interface User {
  id: number
  name: string
  age?: number
}

パターン 2: 年齢プロパティに null 許容

interface User {
  id: number
  name: string
  age: number | null
}

パターン 3: 年齢プロパティに undefined 許容

interface User {
  id: number
  name: string
  age: number | undefined
}

パターン 1 とパターン 3 は似ているようで違います。パターン 1 はプロパティを省略可能ですが、パターン 3 はプロパティを明示し undefined を指定しなければなりません。

また、パターン 2 とパターン 3 も異なります。オブジェクトを JSON に変換した際に、値が undefined の場合はプロパティが省略されます。

> JSON.stringify({
  "id": 42,
  "name": "Marty McFly",
  "age": null
})
'{"id":42,"name":"Marty McFly","age":null}'
> JSON.stringify({
  "id": 42,
  "name": "Marty McFly",
  "age": undefined
})
'{"id":42,"name":"Marty McFly"}'
>

OpenAPI

API 設計の観点ではどうでしょうか。OpenAPI 3.0 では以下のような感じになります。

パターン 1: 年齢プロパティを省略可能

components:
  schemas:
    User:
      type: object
      properties:
        id:
          type: integer
          format: int64
        name:
          type: string
        age:
          type: integer
          format: int32
      required:
        - id
        - name

パターン 2: 年齢プロパティに null 許容

components:
  schemas:
    User:
      type: object
      properties:
        id:
          type: integer
          format: int64
        name:
          type: string
        age:
          type: integer
          format: int32
          nullable: true
      required:
        - id
        - name
        - age

Swagger UI での見た目は以下のようになります。

パターン 1: 年齢プロパティを省略可能
パターン 1: 年齢プロパティを省略可能

パターン 2: 年齢プロパティに null 許容
パターン 2: 年齢プロパティに `null` 許容

OpenAPI では nullable という指定ができるようになったのは 3.0 になってからで、それまでは API 仕様として null を表すことができなかった都合上、パターン 1 がよく使われている印象です。ただし、API 仕様書がない外部 API の返戻値のプロパティが省略されておらず null を値として持っていると助かることが多いです。

ドキュメント DB

ドキュメント DB でプロパティを領略する場合と値として null を許容する場合ではクエリの書き方が変わってきます。ここでは Microsoft Azure の Cosmos DB を例にしてみます。

パターン 1: 年齢プロパティを省略可能

SELECT * FROM c WHERE IS_DEFINED(c.age);
SELECT * FROM c WHERE NOT IS_DEFINED(c.age);

パターン 2: 年齢プロパティに null 許容

SELECT * FROM c WHERE c.age != null;
SELECT * FROM c WHERE c.age = null;

クエリ自体はパターン 1 に Cosmos DB の方言が含まれてはいるものの、どちらがどうである、ということはなさそうです。ただし、このドキュメントの持ち方が API 仕様にも影響を与え、バックエンド、そしてフロントエンドの実装にも影響を与えることになるため、慎重に設計する必要はあります。

JavaScript/TypeScript による判定方法

JavaScript/TypeScript では、プロパティが定義されておらず undefined なのか、値が null なのかを判別する良い方法があります。

const users = [
  {
    "id": 42,
    "name": "Marty McFly"
  },
  {
    "id": 43,
    "name": "Biff Tannen",
    "age": null
  }
]
users.forEach(user => {
  if (user.age != null) { // ← このように判定する
    console.log('Age is:', user.age)
  }
})

undefinednull でないことを判定するには if (user.age) { でもできますが、これだと年齢が 0 歳の場合に正しく動作しません。厳密に行うと if (user.age !== undefined && user.age !== null) { となりますが、これがまさに if (user.age != null) { と表現できるため個人的にこの記法を推奨しています。プロパティがなくて undefined なのか、値を null として値がないことを表現するのかにとらわれずに一貫した書き方ができます。ESLint でも ==!= を使用すると警告が出ますが、null と比較する場合に限り警告は出ません。JavaScript/TypeScript の言語仕様では nullundefined は明確に違いますが、ビジネスロジックで nullundefined に異なる意味づけをしないようにすべきと強く推奨します。

Haskell

唐突に Haskell の話をします。Haskell では存在するかどうかわからない値は Maybe を用いて表現します。JSON と Haskell のデータ型を相互変換するライブラリとして最も使われているのは aeson でしょうから、以下に aeson だとどのようになるかを紹介します。

data User = User
  { id :: Integer
  , name :: String
  , age :: Maybe Int
  } deriving (Show, Generic)

JSON に変換可能なデータの定義方法はいくつかあるのですが、今回は Generic で自動導出することにします。REPL で実行すると以下のことがわかります。

  • Haskell のデータ型では Nothing を明示する必要がある
  • aeson で JSON にエンコードすると Nothingnull になる
  • プロパティが省略されている JSON を aeson でデコードすると Nothing になる
  • aeson ではパターン 1 を読み込み可能としつつ、パターン 2 に統一する方向で動作する
> let user = User {id = 42, name = "Marty McFly", age = Nothing}
> user
User {id = 42, name = "Marty McFly", age = Nothing}
> encode user
"{\"age\":null,\"name\":\"Marty McFly\",\"id\":42}"
> decode "{\"id\":42,\"name\":\"Marty McFly\"}" :: Maybe User
Just (User {id = 42, name = "Marty McFly", age = Nothing})
>

まとめ

オブジェクト、JSON、API、ドキュメントで存在しない値を表現するためにプロパティを省略するか null を許容するかについては、若干の一長一短はあるものの(美意識の差とか、慣習とか、多少のデータ量とか、多少のタイプ量とか)、どちらでも良いかと思います。ただし、システム内やアプリ内で統一すること、値が存在しないことをコードで適切に判定することが大切です。そのために静的型付けが利用できれば更に良いです。

27
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
27
9