API を設計する、ドキュメント DB のデータ構造を定義する、JavaScript/TypeScript の内部的な型を定義する、というような場合、JSON で型設計を行うことになります。その際、避けて通れないのが、値が存在しないかも知れないプロパティの扱いに関する設計方針の検討です。Haskell についても少しだけ言及します。
以下写真は 0、null
、undefined
のとてもわかり易い概念です。
0 vs null vs undefined より引用。
TL;DR
- 実際は
null
でもundefined
でもそれほど大差はない - JavaScript/TypeScript では
== null
、!= null
でnull
とundefined
をまとめて判定したい - 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 コードを書いてみます。
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 での見た目は以下のようになります。
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)
}
})
undefined
や null
でないことを判定するには if (user.age) {
でもできますが、これだと年齢が 0 歳の場合に正しく動作しません。厳密に行うと if (user.age !== undefined && user.age !== null) {
となりますが、これがまさに if (user.age != null) {
と表現できるため個人的にこの記法を推奨しています。プロパティがなくて undefined
なのか、値を null
として値がないことを表現するのかにとらわれずに一貫した書き方ができます。ESLint でも ==
や !=
を使用すると警告が出ますが、null
と比較する場合に限り警告は出ません。JavaScript/TypeScript の言語仕様では null
と undefined
は明確に違いますが、ビジネスロジックで null
と undefined
に異なる意味づけをしないようにすべきと強く推奨します。
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 にエンコードすると
Nothing
はnull
になる - プロパティが省略されている 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
を許容するかについては、若干の一長一短はあるものの(美意識の差とか、慣習とか、多少のデータ量とか、多少のタイプ量とか)、どちらでも良いかと思います。ただし、システム内やアプリ内で統一すること、値が存在しないことをコードで適切に判定することが大切です。そのために静的型付けが利用できれば更に良いです。