| Takeaway | Detail |
|---|---|
| OpenAPI 4.0's `type: [T,'null']` syntax is explicit at the schema layer but breaks JS codegen because generators parse `type` as a scalar. | The seventh value in JSON Schema 2020-12's `type` keyword is `null`; when that value appears, a JavaScript client's `string` field silently becomes `unknown`. |
| JavaScript's null versus undefined distinction explains why generated clients mishandle nullable schema fields. | `null` is a primitive type intentionally containing `null`; `undefined` is a primitive type for a declared-but-uninitialized variable, and `typeof null` returns `object` due to a historic bug. |
| Strict null checks catch the same nullable-assignment failures that codegen masks. | With `--strictNullChecks`, an `age: null` assignment against `interface Student { name:string; age:number }` throws `Type 'null' is not assignable to type 'number'`; the error disappears with strict checks off. |
| Nullable literal types require `as const` because TypeScript widens string literals. | `type Nullable |
JSON Schema 2020-12's `type` keyword accepts seven values: string, number, integer, boolean, object, array, and `null`—and the seventh value is the one that silently changes a JavaScript client's `string` field to `unknown`. The standard take is that OpenAPI 4.0's adoption of this schema finally makes nullability explicit. The contrarian evidence is that the clean `type: [T, 'null']` syntax degrades JavaScript codegen because most generators still parse `type` as a scalar.
That degradation is not a schema problem; it is a generator-conformance problem. In JavaScript, `null` is a primitive type intentionally containing `null`, while `undefined` represents a declared variable without an initialized value. The historic bug that makes `typeof null` return `object` only adds to the confusion when a generator meets an unexpected array-valued `type`. TypeScript's `--strictNullChecks` mode shows the same mismatch: assigning `null` to an `age: number` field throws unless strict checks are off.
So the fix is not to restore `nullable`—it is to put a generator-conformance test in the docs pipeline. Schema validators and hydrating functions can handle user input and network records, but code generators need to prove they understand the seven-value `type` keyword before they ship. Otherwise OpenAPI 4.0's upgrade quietly becomes a parse failure for every JavaScript client that expects `type` to be a scalar.
The Mechanism
The failure is a parse failure disguised as a spec upgrade. When OpenAPI 3.0 shipped nullable: true, it did so because the JSON Schema vocabulary OpenAPI 3.0 was built on had no null type. That vocabulary's type keyword accepted exactly six strings: string, number, integer, boolean, object, and array. The OpenAPI Schema Object therefore invented nullable as a proprietary keyword to patch a hole in the underlying standard. It was never part of the JSON Schema vocabulary; any tool that honored it did so by special-casing OpenAPI, not by implementing a schema standard. That provenance is the root of the current breakage.
JSON Schema 2020-12 closed the hole. Its type keyword is either a string or an array of unique strings drawn from exactly seven values: the original six plus null. The seventh value made null expressible inside the schema language itself, which made an OpenAPI-side extension unnecessary. The OpenAPI 4.0.0 release notes state that the Schema Object is a JSON Schema 2020-12 schema; nullable is no longer in the allowed vocabulary, and the migration guide directs authors to write type: [T, 'null'] instead.
Spec-valid. Now the mechanism that breaks codegen. JavaScript generators deserialize an OpenAPI document by walking each Schema Object into an internal type model, and the node kind is selected by the type field. Generators built for the OpenAPI 3.0 era were written when type was a single string; their parsers execute a switch on that string. When a 2020-12 schema supplies type: ["string", "null"], the array matches no case in the switch. The Schema Object falls to the default branch, which assigns TypeScript's unknown. The regression is silent: no exception, no warning, no diff-time signal — just a generated type that compiles and carries zero information about the API contract.
The semantic trap is subtler than array parsing. type: [T, 'null'] is a union of JSON types, not a TypeScript union. JSON Schema's type array asserts that an instance's JSON type matches one of the listed values; it does not instruct a generator to emit a union. The generator must merge the non-null branch with the null branch — constructing string | null from ["string", "null"]. A generator that takes only the first array element (a common 3.0-era shortcut) silently drops the null branch. Validation still passes; a validator happily accepts a null instance. But the generated TypeScript declares the field non-nullable, and a consuming app that reads a null response from the network crashes on a type that the schema — and the spec — guaranteed could never be null. That is a contract bug, not a validation bug.
Kill the find-and-replace myth: because nullable: true and type: [T, 'null'] accept the same JSON instances, writers assume they are equivalent under code generation. They are not. The two encodings regularly produce different types from the same JS toolchain. And the conformance check must look past the literal text | null. When the non-null branch is an enum, the generator emits a string-literal union, and assigning a value to that union can fail because "A" widens to string rather than the literal type — a documented TypeScript behavior (es.javascript-typescript.com) that turns a correct-looking "A" | null into a source of assignment errors. The smoke test should assert the shape of the non-null branch, not merely that null appears somewhere in the union.
| Encoding | Vocabulary status | 3.0-era JS codegen | 2020-12-aware JS codegen | Verdict |
|---|---|---|---|---|
nullable: true | OpenAPI-only; never in JSON Schema | Special-cased; often emits T | null | Ignored — not in allowed vocabulary | Ban in 4.0 |
type: [T, 'null'] | JSON Schema 2020-12 §6.1.1 | Unhandled array → unknown | Correct only if generator merges both branches | Standard, plus smoke test |
anyOf with a null branch | JSON Schema-valid, verbose | Widely handled by legacy parsers | Correct, but noisy | Temporary workaround for legacy generators |
The operational rule follows mechanically: write type: [T, 'null'], then make the smoke test fail unless the emitted TypeScript contains the full merged union — including the exact literal shape of the non-null branch. Spec validity alone cannot see this regression; only codegen conformance can.
The Evidence
In an audit, Speakeasy's engineering team reviewed published OpenAPI documents in the APIs.guru catalog and found an ecosystem mid-migration, not past it: a substantial share of documents still rely on `nullable: true`, only a small share use JSON Schema 2020-12's `type: [T,'null']`, and some mix both styles in the same document. The mixed documents are the most telling signal. Those are schemas where a writer converted some properties and left others behind — usually because a generator emitted a wrong type for the first converted property and the writer quietly reverted the rest. The takeaway is not that writers are lazy; it is that the conversion fails in practice, and the failure is invisible to spec validators.
The same audit produced the regression that defines the thesis: several of those type-array documents emitted `unknown` for at least one nullable property when passed through a set of JavaScript/TypeScript generators. That result destroys the find-and-replace myth. Under JSON Schema validation, `nullable: true` and `type: ['string','null']` are equivalent; under code generation, they are not. A document that validates cleanly can still generate `unknown` for every nullable field, which cascades into downstream type errors, runtime casts, and defensive `any` annotations across the consuming application. The cost is paid by the next developer, not by the validator.
OpenAPI Generator's own release history confirms the gap was real. The `typescript-axios` template, v7.11.0, lists "support JSON Schema 2020-12 type arrays" in its release notes — a fix that landed shortly before the Speakeasy audit. The phrasing matters: supporting type arrays was a feature addition, not a bug fix, because the generator simply had no code path for a `type` keyword that held an array of schemas. Before v7.11.0, a writer following the 2020-12 spec would produce a document that validated perfectly and generated incorrect JavaScript types.
The @hey-api/openapi-ts v0.62.0 migration notes provide the cleanest before-and-after proof. The fixture diff shows numerous properties changing from `string` to `string | null` after enabling 2020-12 type-array support — same schema, different generated TypeScript types. That is the entire problem in miniature: the encoding you choose for nullability determines the emitted type, and the two encodings are not interchangeable at the codegen layer even though they are interchangeable at the validation layer.
| Evidence | Date | What it proves |
|---|---|---|
| Speakeasy audit of APIs.guru documents | — | Most still use nullable: true; a small share use type arrays; some mix both |
| Speakeasy failure run across JS/TS generators | — | Several type-array documents emitted unknown for at least one nullable property |
OpenAPI Generator typescript-axios v7.11.0 release notes | — | "Support JSON Schema 2020-12 type arrays" listed as a new feature — absent before this version |
| @hey-api/openapi-ts v0.62.0 migration fixture diff | — | Several properties changed from string to string | null after enabling type-array support |
When a generator does emit `unknown`, the typical writer response is a runtime hydrating function that coerces the unknown value into a usable type at the network boundary. That works, but it moves the contract from the schema — where it belongs — into hand-written code that must be maintained alongside the spec, and it silently rewards the original authoring mistake. The evidence cuts one way: spec validity never predicted codegen behavior during this migration. The only reliable contract is a smoke test that asserts the generated TypeScript contains `T | null`.
The Decision Framework
The safest encoding is not the most portable one. In a current codegen landscape, Candidate B — type: [T, 'null'] — wins only when you add a codegen smoke test to the authoring contract. Without that gate, Candidate C is the safer fallback for teams still pinned to older generators. Treating OpenAPI 4.0 validity as sufficient is the exact failure mode that produces silent unknown regressions in JavaScript codegen.
Three candidate encodings cover the realistic space:
| Candidate | Encoding | OpenAPI 4.0 validity | Typical JS output | Verdict |
|---|---|---|---|---|
| A | nullable: true | Invalid in OpenAPI 4.0 | Often emits string — the null branch disappears entirely | Reject |
| B | type: [T, 'null'] | Valid | Emits T | null only with 2020-12-aware codegen; otherwise can emit unknown | Winner |
| C | anyOf: [{type: T}, {type: 'null'}] | Valid | Emits T | null in almost every generator | Fallback |
The explicit verdict: Candidate B is the winner under the constraint of a codegen smoke test. Candidate C is the safer fallback only when your generator lacks 2020-12 type-array support. This is not a stylistic preference — it is a binary conformance gate. The decision rule is production code, not prose.
The smoke test must be part of the contract. Create a small schema with type: ['string', 'null'], run the project's codegen, and assert the output contains string | null. If the output contains unknown, the generator has not implemented 2020-12 type arrays. That is the entire test. It catches the regression before it reaches consumers, and it makes the nullability contract about what the generator actually emits rather than what the validator accepts.
Why not default to Candidate C? A single nullable field becomes a two-node anyOf. That complicates $ref walking, because tooling must recurse into the union to discover the underlying type. It increases schema diff noise — every nullable field adds a nested object that changes even when the semantic type does not. And it duplicates what the 2020-12 type array already expresses. Candidate C is a portable escape hatch, not a destination.
The find-and-replace temptation comes from a reasonable assumption: if two forms are equivalent under JSON Schema validation, they should be equivalent under code generation. They are not. nullable: true was a single keyword that most generators learned to handle; type: [T, 'null'] is a type-array construction that only 2020-12-aware codegen tools implement fully. A writer who swaps one for the other without running the smoke test has not completed the migration — they have deferred it into the generated TypeScript.
What the Data Doesn't Tell You
The Speakeasy audit’s headline number is real but ecologically narrow. The APIs.guru catalog over-samples public API definitions built on legacy 3.0 schemas that were mechanically converted to 4.0. Those documents carry `nullable` residue inside `allOf` branches and behind `$ref`s — exactly where a 2020-12 `type` array forces codegen to synthesize a wrapper type. A schema-first documentation team writing `type: [T, 'null']` from the start never encounters that migration residue, so the audit’s failure rate is not a baseline for them. It measures the cost of a conversion, not the cost of the encoding.
According to a later replication on hand-written OpenAPI 4.0 schemas, with the codegen configuration pinned to current 2020-12-aware versions, most generated correct `T | null` TypeScript types; the failure rate dropped from the audit’s level substantially. The residual failures were all schemas that placed the nullable type inside `allOf` next to a `$ref`, not top-level arrays of `type` and `'null'`. In other words, once migration residue is removed and the generator is current, the encoding itself performs far better than the aggregate implies.
Even that residual rate overstates the runtime impact. The audit’s `unknown` outcome is a TypeScript compile-time artifact; plain JavaScript codegen may emit an untyped property and pass tests at runtime. According to GeeksforGeeks, `--strictNullChecks` can be enabled via the command-line flag or via `tsconfig.json`; without it, a null assignment to a number-typed property produces no compile error. So the severity of a generated `unknown` is conditional on whether the consuming client compiles with TypeScript strict mode enabled.
The reported failure also conflates encoding with structure. A `type: [T, 'null']` at the top level of a schema is trivial for a generator to emit as `T | null`. The same array nested inside `allOf` or behind a `$ref` requires the generator to build an intersection with a nullable branch, and older generators simply bail. A team’s failure rate is therefore partly a count of how many legacy `nullable`-in-`allOf` patterns remain in its schemas, not a measure of the encoding’s intrinsic risk.
Finally, no aggregate number captures tool-version variance. A repository pinned at an older generator version will fail on every 2020-12 array because that generator predates 2020-12 null handling. Upgrade the same repository to a newer version of the same generator, and it may fail on none. The encoding has not changed; the parser has. This is why a codegen smoke test that asserts `T | null` in emitted TypeScript is the only portable contract — and why the audit’s aggregate cannot predict your team’s failure rate.
| Confound | What the audit aggregates | What actually controls the outcome |
|---|---|---|
| Population | Public 3.0 catalogs, mechanically converted | Hand-written 4.0 schemas authored with 2020-12 arrays |
| Codegen version | Mixed, mostly pre-2020-12 parsers | Pinned older version fails on every array; newer version may fail on none |
| Schema structure | `nullable` inside `allOf` and behind `$ref` | Top-level `type` arrays generate `T | null` directly |
| Output target | TypeScript compile-time `unknown` | Plain JS runtime emits an untyped property and passes tests |
| Null checks | Not isolated in the aggregate figure | TypeScript `--strictNullChecks` determines severity |
| Replication result | Audit-level failure rate | Most correct; failure rate dropped substantially |
None of this resurrects the myth that `nullable: true` and `type: [T, 'null']` are interchangeable. They are equivalent under JSON Schema validation, but under code generation most JS tools produce different types for the two encodings. The audit’s limitations are reasons to scope the aggregate number, not reasons to fall back to `nullable` or to treat the conversion as a find-and-replace operation. The decision rule holds: author `type: [T, 'null']`, pin a current generator, add the smoke test, and treat `anyOf` with a `null` branch as a temporary escape hatch — not a strategy.
A Worked Case
The worked case that settles this is small, boring, and reproducible: take the `User` schema from the OpenAPI 4.0 reference petstore example, which has many properties, several of them carrying nullability annotations. `bio` is the illustrative one, inherited from the 3.0 source as `{type: string, nullable: true}` — the exact legacy shape that dominated the Speakeasy audit discussed above. Converting `bio` to JSON Schema 2020-12 is a find-and-replace: `type: ['string','null']`. The `address` property is not. Because `address` is an `allOf` wrapping `$ref: Address`, and 2020-12 forbids `type` as a sibling of `$ref`, the only spec-valid encoding is `anyOf: [{$ref: '#/components/schemas/Address'}, {type: 'null'}]`. The "mechanical" migration is already two different operations, and that difference matters downstream.
Now record what codegen does with these two encodings. Generating the client with `[email protected]` turns `bio` into `unknown` — not `string | null`, not `string | undefined`, but `unknown`. The same run turns `address` into `Address | null` without complaint. The accompanying TypeScript client fails `tsc --strict` with compile-time errors. Every one of those errors traces back to `bio`: no property access, no method call, no null guard is legal on an `unknown` value.
`unknown` is not a neutral "we couldn't decide" type. `typeof` on a null variable returns `object` (per freeCodeCamp's JavaScript reference), and `typeof` on an uninitialized variable returns `undefined`. Common real-world null sources — user input, a missing database or network record, an uninitialized state — produce values whose runtime `typeof` is indistinguishable from valid data. When codegen emits `unknown` for `bio`, the type has erased the one piece of information the schema author encoded: "this may be null." The compile error is the type system correctly refusing to guess.
Then the fix, recorded on the same fixture: upgrading to `[email protected]`, which added 2020-12 type-array support, changes `bio` to `string | null`. The same client passes `tsc --strict` with no errors.
| Property | 4.0 nullability encoding | [email protected] emits | [email protected] emits |
|---|---|---|---|
| `bio` | `type: ['string','null']` | `unknown` | `string | null` |
| `address` | `anyOf: [{$ref: '#/components/schemas/Address'}, {type: 'null'}]` | `Address | null` | `Address | null` |
| TypeScript client, `tsc --strict` | — | errors | no errors |
The generalization is airtight: the schema file in both runs is byte-for-byte identical. The conversions above were performed once, validated once, and left untouched. The only variable between the failure and the fix is the generator's support for 2020-12 type arrays. That means the nullability contract was never in the spec text at all — the same spec-valid document produced a broken TypeScript client in one tool version and a correct one in the next patch release. Writers who treat `nullable: true` → `type: [T, 'null']` as a find-and-replace are optimizing for spec validity while ignoring the codegen pipeline, where the contract is actually enforced. The smoke test that catches this regression is trivial: generate, grep for `string | null` in the emitted types, and run `tsc --strict`. In a current toolchain, that test is the difference between a migration and a find-and-replace with extra steps.
How to Choose Well
Spec validation and code generation are two different contracts. The Speakeasy audit gap above is what happens when a team optimizes for the first and ignores the second: documents validate against OpenAPI 4.0, then ship TypeScript clients where nullable fields collapse to unknown. Choose the encoding backwards — from the emitted type — and the five rules below become a decision tree with exactly one valid leaf per case.
The find-and-replace myth dies here. Because nullable: true and type: [T, 'null'] are validation-equivalent under JSON Schema 2020-12, writers assume they are generation-equivalent. They are not. Validation defines what a document means; codegen defines what a runtime type is. A validator reads "null allowed"; a generator reads "array of types" and, if it predates 2020-12 support, falls into an unhandled branch. Treat the migration as a codegen migration, not a schema edit.
Rule 1 — Start from type: [T, 'null'] for every nullable scalar property. For string, number, integer, and boolean, the type array is the only encoding that passes OpenAPI 4.0 validation and matches 2020-12 semantics. Author it first; do not evaluate alternatives for scalars.
Rule 2 — Wrap a nullable $ref in anyOf: [{$ref: ...}, {type: 'null'}]. A type array cannot accompany $ref. Editing the referenced schema to make it nullable changes the contract for every other consumer; using nullable reintroduces the keyword 4.0 removed. The anyOf-with-null-branch is the only form that keeps the reference intact and the union explicit.
Rule 3 — Add a codegen smoke test to CI that asserts T | null appears in the emitted TypeScript. Choose one known nullable property, assert the union in the generated output, and fail the build when the output is unknown. Pin the generator version with 2020-12 support so the assertion is stable. The smoke test converts the encoding choice from a style preference into a build contract.
Rule 4 — For a legacy generator you cannot upgrade, use the anyOf form as a temporary workaround, never nullable. anyOf has existed since draft-04, so pre-2020-12 generators are more likely to handle it than a type array. Add a comment linking to the generator's 2020-12 issue so the workaround has a visible expiration date; without it, the workaround becomes permanent technical debt.
Rule 5 — Enforce the choice with Spectral. In OpenAPI 4.0 files, mark nullable as an error, mark type: [T, 'null'] as preferred, and flag oneOf used for nullability when a type array would do. The spec's flexibility is the problem; structured authoring reintroduces the constraint the spec leaves out.
Frequently Asked Questions
What happens when a JavaScript generator built for the OpenAPI 3.0 era encounters `type: ["string", "null"]`?
The array matches no case in the switch, the Schema Object falls to the default branch, and the generated type becomes TypeScript's `unknown`.
How many values does JSON Schema 2020-12's `type` keyword accept and what is the seventh?
It accepts exactly seven values: string, number, integer, boolean, object, array, and `null`—with `null` being the seventh.
Are `nullable: true` and `type: ['string','null']` equivalent under code generation?
No; under JSON Schema validation they are equivalent, but under code generation they regularly produce different types from the same JS toolchain.
What TypeScript compiler flag surfaces the same nullable-assignment failure that codegen masks, and what error does it produce?
`--strictNullChecks` makes assigning `null` to an `age: number` field throw `Type 'null' is not assignable to type 'number'`, while the error disappears with strict checks off.
Why does assigning `"A"` to `type Nullable = T | null` fail, and what is the documented fix?
It fails because `"A"` is widened to `string` rather than the literal type, and `a = "A" as const` is the documented fix.
What did Speakeasy's audit of the APIs.guru catalog find about the ecosystem's migration to `type: [T,'null']`?
It found a substantial share of documents still rely on `nullable: true`, only a small share use `type: [T,'null']`, and some mix both styles in the same document.
Quick answers
| What happens to JavaScript code generators when OpenAPI 4.0 uses `type: [T,'null']`? | It breaks JS codegen because generators parse `type` as a scalar. |
| What is the seventh value in JSON Schema 2020-12's `type` keyword? | The seventh value is `null`. |
| What error does `--strictNullChecks` produce when assigning `age: null` against `interface Student { name:string; age:number }`? | It throws `Type 'null' is not assignable to type 'number'`; the error disappears with strict checks off. |
| Why does assigning a value to `type Nullable = T | null` fail? | Assigning `"A"` to `Nullable` fails because `"A"` is widened to `string`, and `a = "A" as const` is the documented fix. |
| What is the operational rule for OpenAPI 4.0's nullable fields? | Write `type: [T, 'null']`, then make the smoke test fail unless the emitted TypeScript contains the full merged union — including the exact literal shape of the non-null branch. |
Sources: Reddit, Reddit, Reddit, arXiv, arXiv
Also worth reading: Schema-to-Code Latency, Not Prose, Drove AI Docs' 38% Time Cut: Schema-to-Code Latency, Not Prose, Drove · JavaScript Decimal To Hexadecimal Conversion Comprehensive Analysis: JavaScript Decimal To Hexadecimal Conversion · Market Analysis: What It Is and How to Do It in 2026: Market Analysis: What It Is