JSON Schema Nullable Fields: null, type Arrays, and OpenAPI

Last updated:

In JSON Schema, null is a first-class type — a field that can be either a string or null requires an explicit schema change. This guide covers the type array syntax, the difference between nullable and optional, OpenAPI 3.0 vs 3.1 nullable semantics, Zod's .nullable(), and TypeScript implications.

1. Syntax Across JSON Schema Drafts

// Draft 2020-12 / 2019-09 — standard, recommended
{
  "type": "object",
  "properties": {
    "name":      { "type": "string" },
    "bio":       { "type": ["string", "null"] },
    "deletedAt": { "type": ["string", "null"], "format": "date-time" }
  },
  "required": ["name", "bio"]
}

// Draft 4/7 — type arrays still work (most validators support this)
{ "type": ["string", "null"] }

// Draft 4/7 — alternative with oneOf (more verbose, same semantics)
{
  "oneOf": [
    { "type": "string" },
    { "type": "null" }
  ]
}

The type array form is shorter and validates faster. Use it unless a generator forces you to use oneOf.

2. Nullable vs Optional — Key Distinction

// Schema: bio is NULLABLE but REQUIRED
{
  "type": "object",
  "properties": {
    "bio": { "type": ["string", "null"] }
  },
  "required": ["bio"]   // bio must be present
}

// Valid:   { "bio": "Some text" }
// Valid:   { "bio": null }
// Invalid: {}              ← bio is absent (required violation)

// Schema: email is OPTIONAL but NOT nullable
{
  "type": "object",
  "properties": {
    "email": { "type": "string", "format": "email" }
  }
  // "required" does not include "email"
}

// Valid:   { "email": "a@example.com" }
// Valid:   {}              ← email absent is fine
// Invalid: { "email": null }  ← null is not a string
// TypeScript equivalents:
interface User {
  bio: string | null       // nullable + required
  email?: string           // optional, not nullable
  deletedAt?: string | null // optional AND nullable
}

3. OpenAPI 3.0 vs 3.1

# OpenAPI 3.0 (NOT standard JSON Schema)
components:
  schemas:
    User:
      type: object
      properties:
        bio:
          type: string
          nullable: true   # ← OpenAPI extension, ignored by JSON Schema validators

# OpenAPI 3.1 (standard JSON Schema Draft 2020-12)
components:
  schemas:
    User:
      type: object
      properties:
        bio:
          type: [string, "null"]  # ← standard syntax

If you validate OpenAPI 3.0 schemas with Ajv directly, nullable: true is silently ignored (Ajv does not know this keyword). Use Jsonic's JSON Schema validator or apply an OpenAPI 3.0 → JSON Schema transform before validation.

4. Zod Nullable and Optional

import { z } from 'zod'

const UserSchema = z.object({
  id:         z.number(),
  name:       z.string(),
  bio:        z.string().nullable(),           // string | null (must be present)
  email:      z.string().email().optional(),   // string | undefined (can be absent)
  deletedAt:  z.string().datetime().nullish(), // string | null | undefined
})

type User = z.infer<typeof UserSchema>
// {
//   id: number
//   name: string
//   bio: string | null
//   email?: string | undefined
//   deletedAt?: string | null | undefined
// }

// Convert to JSON Schema (npm install zod-to-json-schema)
import zodToJsonSchema from 'zod-to-json-schema'
const jsonSchema = zodToJsonSchema(UserSchema, { $schemaUrl: false })
// bio → { type: ["string", "null"] }
// email → { type: "string", format: "email" } (not in required array)
// deletedAt → { type: ["string", "null"], format: "date-time" } (not in required)

5. Ajv Validation Example

import Ajv from 'ajv'
import addFormats from 'ajv-formats'

const ajv = new Ajv()
addFormats(ajv)

const schema = {
  type: 'object',
  properties: {
    name:      { type: 'string' },
    bio:       { type: ['string', 'null'] },
    deletedAt: { type: ['string', 'null'], format: 'date-time' },
  },
  required: ['name', 'bio'],
  additionalProperties: false,
}

const validate = ajv.compile(schema)

validate({ name: 'Alice', bio: null })                     // ✅ true
validate({ name: 'Alice', bio: 'Developer' })              // ✅ true
validate({ name: 'Alice', bio: null, deletedAt: null })    // ✅ true
validate({ name: 'Alice' })                                // ❌ false — bio missing
validate({ name: 'Alice', bio: 42 })                       // ❌ false — wrong type

6. Nullable in JSON Schema with Type-Specific Constraints

// A price that is either a positive number or null
{
  "oneOf": [
    { "type": "number", "minimum": 0, "exclusiveMinimum": 0 },
    { "type": "null" }
  ]
}

// A description that is either a non-empty string or null
{
  "oneOf": [
    { "type": "string", "minLength": 1, "maxLength": 1000 },
    { "type": "null" }
  ]
}

// Multiple types with constraints — anyOf lets multiple match (less strict)
{
  "anyOf": [
    { "type": "string", "maxLength": 100 },
    { "type": "number" },
    { "type": "null" }
  ]
}

7. Nullable vs null Default Values

// "default" provides a default value if the property is absent
// For nullable fields, null is a valid default
{
  "type": "object",
  "properties": {
    "discountCode": {
      "type": ["string", "null"],
      "default": null   // if absent, treat as null
    }
  }
}

// Note: JSON Schema "default" is advisory — validators do not apply defaults.
// Ajv's useDefaults option fills in defaults during validation:
const ajv = new Ajv({ useDefaults: true })
const data = {}
validate(data) // after this, data.discountCode === null

Frequently Asked Questions

How do I allow null in a JSON Schema field?

In JSON Schema Draft 2020-12 (and Draft 2019-09), use a type array: {"type": ["string", "null"]}. This means the value must be either a string or null. In Draft 4/6/7, you have two options: the same type array syntax (supported by most validators) or oneOf: {"oneOf": [{"type": "string"}, {"type": "null"}]}. The type array form is shorter and the recommended approach for modern schemas. Example: {"type": "object", "properties": {"middleName": {"type": ["string", "null"]}, "deletedAt": {"type": ["string", "null"], "format": "date-time"}}}. Note: in JSON Schema, null is its own type distinct from false, 0, or empty string. A value that omits the property entirely (undefined in JavaScript) is different from a property set to null — null is explicit absence of a value.

What is the difference between nullable and optional in JSON Schema?

These are two separate concepts in JSON Schema that are often confused. Optional means the property may be absent from the object entirely — controlled by the required array. A property not in required is optional: if the object is {"name": "Alice"} and "email" is not in required, the object is valid even without an "email" key. Nullable means the property must be present but its value may be null — controlled by including "null" in the type array. {"email": null} is valid for a nullable email, but {} is not (because email is absent, not null). A field can be both optional AND nullable: optional means it can be absent; if it is present, null is an allowed value. In OpenAPI, these map to: required (in the object's required array) vs nullable: true (OpenAPI 3.0) or type: [string, null] (OpenAPI 3.1). In TypeScript: optional is "email?: string" (string | undefined), nullable is "email: string | null", both is "email?: string | null".

How does OpenAPI 3.0 handle nullable vs OpenAPI 3.1?

OpenAPI 3.0 uses a non-standard nullable: true extension keyword because OpenAPI 3.0 is based on JSON Schema Draft 4 which does not support type arrays: {"type": "string", "nullable": true}. This is an OpenAPI-specific extension not recognized by JSON Schema validators. OpenAPI 3.1 is fully aligned with JSON Schema Draft 2020-12 and uses standard type arrays instead: {"type": ["string", "null"]}. When migrating from OpenAPI 3.0 to 3.1, replace nullable: true with type: [originalType, null] everywhere. Tools like Swagger UI, Redocly, and openapi-generator handle both forms, but if you are running JSON Schema validation with Ajv on OpenAPI 3.0 schemas, nullable: true is silently ignored — you need to apply a transform to convert it to a type array first. The openapi-to-jsonschema package handles this conversion.

How do I handle nullable fields with Ajv in JavaScript?

Ajv (versions 6.x-8.x) supports the type array form natively. Example: const schema = { type: "object", properties: { email: { type: ["string", "null"] } }, required: ["email"] }. The value null passes validation, an empty string or undefined does not. For OpenAPI 3.0 schemas with nullable: true, Ajv does not understand this keyword unless you add the ajv-openapi plugin or manually convert nullable schemas. With Ajv 8.x (default for JSON Schema Draft 2020-12), use type arrays. Full example: const Ajv = require("ajv"); const ajv = new Ajv(); const validate = ajv.compile({ type: "object", properties: { name: { type: "string" }, bio: { type: ["string", "null"] } }, required: ["name", "bio"] }); validate({ name: "Alice", bio: null }) // → true; validate({ name: "Alice" }) // → false (bio is required).

How does Zod handle nullable vs optional fields?

Zod has explicit .nullable() and .optional() modifiers that map directly to TypeScript types. z.string().nullable() accepts string | null and produces TypeScript type string | null. z.string().optional() accepts string | undefined and produces string | undefined. z.string().nullish() accepts string | null | undefined (shorthand for .nullable().optional()). For JSON API responses where a field might be null but is always present in the object, use .nullable(). For fields that might be absent from the object, use .optional(). Example schema: const UserSchema = z.object({ id: z.number(), name: z.string(), bio: z.string().nullable(), // null allowed but must be present deletedAt: z.string().datetime().nullable().optional(), // may be absent or null }). The inferred TypeScript type from Static<typeof UserSchema> correctly reflects these nullability constraints. Zod also converts to JSON Schema via zod-to-json-schema, which maps .nullable() to {"type": ["string", "null"]} in Draft 2020-12.

Should I use null or omit the property when a value is absent?

This is a design question with different industry conventions. The JSON:API specification uses null for an empty to-one relationship: {"data": null} means "no related resource." GraphQL always includes requested fields but uses null for absent values. REST APIs vary. The practical considerations: (1) null is explicit — the field is present and the caller knows the value is absent intentionally. Omitting the property is ambiguous — did the server not include it because it is null, or because the client lacks permission to see it? (2) null survives JSON round-trips; omitted properties do not — JSON.parse(JSON.stringify({a: undefined})) gives {} (the property is lost). (3) For PATCH endpoints, null usually means "clear this field" while omitting the property means "leave unchanged" — mixing these semantics in the same API is confusing. Recommendation: use null for "value is known to be absent" and omit for "value is not returned in this context." Be consistent within your API and document it.

How do I write a JSON Schema for a field that allows string, number, or null?

For a field that allows multiple types including null, list all allowed types in the type array: {"type": ["string", "number", "null"]}. This is cleaner than oneOf with three branches. If you also need type-specific constraints (e.g., string has maxLength, number has minimum), use oneOf or anyOf: {"oneOf": [{"type": "string", "maxLength": 100}, {"type": "number", "minimum": 0}, {"type": "null"}]}. The difference between oneOf and anyOf here: oneOf requires exactly one subschema to match — if a value could match multiple schemas (e.g., the number 42 matching both number and a string coercible to number), oneOf fails. anyOf requires at least one to match, which is less strict. For nullable fields with no type-specific constraints, prefer the type array over oneOf — it is shorter, clearer, and validates faster in Ajv.

How does TypeScript infer types from a nullable JSON Schema when using quicktype or openapi-generator?

Code generators convert JSON Schema type arrays to TypeScript union types. quicktype converts {"type": ["string", "null"]} to string | null in generated TypeScript. openapi-generator converts OpenAPI 3.0 nullable: true to string | null (the exact form depends on the generator and template). openapi-generator with OpenAPI 3.1 type arrays also produces string | null. The generated code usually also handles optional properties: a property not in the required array is generated with ?: (optional) modifier. The combination of nullable + optional produces string | null | undefined, which openapi-generator represents as string | null with a ?: accessor. If you are writing TypeScript types manually to match a JSON Schema: {"type": ["string", "null"]} → string | null; not in required → ?: modifier; both → property?: string | null or the equivalent string | null | undefined (which TypeScript treats equivalently for optional properties).

Further reading and primary sources