From fce2753f3931d2155ceac88568cf9f0856f993e3 Mon Sep 17 00:00:00 2001
From: Iivo Kapanen <iivo.kapanen@vincit.fi>
Date: Fri, 16 Aug 2024 14:57:17 +0300
Subject: [PATCH] Add support for nullish option in OpenAPI 3.0.3

- Handle nullish with a union of Typebox's Type.Null() and the actual type so that the schema accepts null as value
---
 src/writer.js           | 73 ++++++++++++++++++++++++++++-------------
 test/index.js           | 10 ++++++
 test/index.js.snapshot  |  4 +++
 test/test-nullable.yaml | 27 +++++++++++++++
 4 files changed, 91 insertions(+), 23 deletions(-)
 create mode 100644 test/test-nullable.yaml

diff --git a/src/writer.js b/src/writer.js
index 1a94169..622c0a6 100644
--- a/src/writer.js
+++ b/src/writer.js
@@ -154,10 +154,11 @@ export const write = async (source, opts = {}) => {
   }
 
   function writeType(schema, isRequired = false) {
+    const isNullable = !!schema.nullable
     schema = cleanupSchema(schema)
 
     if (schema[kRef]) {
-      writeRef(schema, isRequired)
+      writeRef(schema, isRequired, isNullable)
       return
     }
 
@@ -167,7 +168,7 @@ export const write = async (source, opts = {}) => {
     }
 
     if (schema.const) {
-      writeLiteral(schema, isRequired)
+      writeLiteral(schema, isRequired, isNullable)
       return
     }
 
@@ -175,12 +176,12 @@ export const write = async (source, opts = {}) => {
       writeCompound({
         ...schema,
         anyOf: schema.enum,
-      }, isRequired)
+      }, isRequired, isNullable)
       return
     }
 
     if (schema.anyOf || schema.allOf || schema.oneOf) {
-      writeCompound(schema, isRequired)
+      writeCompound(schema, isRequired, isNullable)
       return
     }
 
@@ -194,21 +195,39 @@ export const write = async (source, opts = {}) => {
     }
 
     if (schema.type === 'object') {
-      writeObject(schema, isRequired)
+      writeObject(schema, isRequired, isNullable)
       return
     }
 
     if (schema.type === 'array') {
-      writeArray(schema, isRequired)
+      writeArray(schema, isRequired, isNullable)
       return
     }
 
     if (schema.type in scalarTypes) {
-      writeScalar(schema, isRequired)
+      writeScalar(schema, isRequired, isNullable)
     }
   }
 
-  function writeLiteral(schema, isRequired = false) {
+  /**
+   * @param {boolean} isRequired
+   * @param {boolean} isNullable
+   */
+  function startNullish(isRequired, isNullable) {
+    if (!isRequired) w.write('T.Optional(')
+    if (isNullable) w.write('T.Union([T.Null(), ')
+  }
+
+  /**
+   * @param {boolean} isRequired
+   * @param {boolean} isNullable
+   */
+  function endNullish(isRequired, isNullable) {
+    if (isNullable) w.write('])')
+    if (!isRequired) w.write(')')
+  }
+
+  function writeLiteral(schema, isRequired = false, isNullable = false) {
     let { const: value } = schema
 
     let options = extractSchemaOptions(schema)
@@ -221,10 +240,12 @@ export const write = async (source, opts = {}) => {
       options = ''
     }
 
-    w.write(`${isRequired ? '' : 'T.Optional('}T.Literal(${value}${options})${isRequired ? '' : ')'}`)
+    startNullish(isRequired, isNullable)
+    w.write(`T.Literal(${value}${options})`)
+    endNullish(isRequired, isNullable)
   }
 
-  function writeRef(schema, isRequired = false) {
+  function writeRef(schema, isRequired = false, isNullable = false) {
     let options = extractSchemaOptions(schema)
     if (Object.keys(options).length) {
       options = `,${JSON.stringify(options)}`
@@ -233,13 +254,15 @@ export const write = async (source, opts = {}) => {
     }
 
     const value = `CloneType(${schema[kRef]}${options})`
-    w.write(`${isRequired ? '' : 'T.Optional('}${value}${isRequired ? '' : ')'}`)
+    startNullish(isRequired, isNullable)
+    w.write(`${value}`)
+    endNullish(isRequired, isNullable)
   }
 
-  function writeCompound(schema, isRequired = false) {
+  function writeCompound(schema, isRequired = false, isNullable = false) {
     const { enum: _, type, anyOf, allOf, oneOf, ...options } = schema
 
-    if (!isRequired) w.write('T.Optional(')
+    startNullish(isRequired, isNullable)
 
     const compoundType = anyOf
       ? 'T.Union' // anyOf
@@ -271,13 +294,13 @@ export const write = async (source, opts = {}) => {
 
     w.write(')')
 
-    if (!isRequired) w.write(')')
+    endNullish(isRequired, isNullable)
   }
 
-  function writeObject(schema, isRequired = false) {
+  function writeObject(schema, isRequired = false, isNullable = false) {
     const { type, properties = {}, required = [], ...options } = schema
 
-    if (!isRequired) w.write('T.Optional(')
+    startNullish(isRequired, isNullable)
 
     let optionsString
     const optionsKeys = Object.keys(options)
@@ -330,14 +353,16 @@ export const write = async (source, opts = {}) => {
       w.write(')')
     }
 
-    if (!isRequired) w.write(')')
+    endNullish(isRequired, isNullable)
   }
 
-  function writeScalar(schema, isRequired = false) {
+  function writeScalar(schema, isRequired = false, isNullable = false) {
     let { type, ...options } = schema
 
     if (type === 'string' && options?.format === 'binary') {
-      w.write(`${isRequired ? '' : 'T.Optional('}Binary()${isRequired ? '' : ')'}`)
+      startNullish(isRequired, isNullable)
+      w.write('Binary()')
+      endNullish(isRequired, isNullable)
       return
     }
 
@@ -347,13 +372,15 @@ export const write = async (source, opts = {}) => {
       options = ''
     }
 
-    w.write(`${isRequired ? '' : 'T.Optional('}T.${scalarTypes[type]}(${options})${isRequired ? '' : ')'}`)
+    startNullish(isRequired, isNullable)
+    w.write(`T.${scalarTypes[type]}(${options})`)
+    endNullish(isRequired, isNullable)
   }
 
-  function writeArray(schema, isRequired = false) {
+  function writeArray(schema, isRequired = false, isNullable = false) {
     const { type, items, ...options } = schema
 
-    if (!isRequired) w.write('T.Optional(')
+    startNullish(isRequired, isNullable)
 
     const isArray = Array.isArray(items)
 
@@ -386,7 +413,7 @@ export const write = async (source, opts = {}) => {
 
     w.write(')')
 
-    if (!isRequired) w.write(')')
+    endNullish(isRequired, isNullable)
   }
 
   function buildSchema(paths, pathKey, method) {
diff --git a/test/index.js b/test/index.js
index e76789a..439ca1d 100644
--- a/test/index.js
+++ b/test/index.js
@@ -390,3 +390,13 @@ test('parse some openapi examples', async (t) => {
     await write('https://raw.githubusercontent.com/openai/openai-openapi/master/openapi.yaml')
   })
 })
+
+test('nullable test', async (t) => {
+  await writeFile('./tmp/test-nullable.yaml.js', await write('./test/test-nullable.yaml'))
+  t.assert.snapshot(await readFile('./tmp/petstore.yaml.js', 'utf8'))
+  const { components } = await import('../tmp/test-nullable.yaml.js')
+  assert.deepEqual(components.schemas.Test, Type.Union([Type.Null(), Type.Object({
+    testStr: Type.Optional(Type.Union([Type.Null(), Type.String({ minLength: 2, maxLength: 2 })])),
+    testArr: Type.Union([Type.Null(), Type.Array(Type.Number())]),
+  })]))
+})
diff --git a/test/index.js.snapshot b/test/index.js.snapshot
index 52a7492..f30fc2f 100644
--- a/test/index.js.snapshot
+++ b/test/index.js.snapshot
@@ -6,6 +6,10 @@ exports[`basic test > esm 1`] = `
 "/* eslint eslint-comments/no-unlimited-disable: off */\\n/* eslint-disable */\\n// This document was generated automatically by openapi-box\\n\\n/**\\n * @typedef {import('@sinclair/typebox').TSchema} TSchema\\n */\\n\\n/**\\n * @template {TSchema} T\\n * @typedef {import('@sinclair/typebox').Static<T>} Static\\n */\\n\\n/**\\n * @typedef {import('@sinclair/typebox').SchemaOptions} SchemaOptions\\n */\\n\\n/**\\n * @typedef {{\\n *  [Path in keyof typeof schema]: {\\n *    [Method in keyof typeof schema[Path]]: {\\n *      [Prop in keyof typeof schema[Path][Method]]: typeof schema[Path][Method][Prop] extends TSchema ?\\n *        Static<typeof schema[Path][Method][Prop]> :\\n *        undefined\\n *    }\\n *  }\\n * }} SchemaType\\n */\\n\\n/**\\n * @typedef {{\\n *  [ComponentType in keyof typeof _components]: {\\n *    [ComponentName in keyof typeof _components[ComponentType]]: typeof _components[ComponentType][ComponentName] extends TSchema ?\\n *      Static<typeof _components[ComponentType][ComponentName]> :\\n *      undefined\\n *  }\\n * }} ComponentType\\n */\\n\\nimport { Type as T, TypeRegistry, Kind, CloneType } from '@sinclair/typebox'\\nimport { Value } from '@sinclair/typebox/value'\\n\\n/**\\n * @typedef {{\\n *  [Kind]: 'Binary'\\n *  static: string | File | Blob | Uint8Array\\n *  anyOf: [{\\n *    type: 'object',\\n *    additionalProperties: true\\n *  }, {\\n *    type: 'string',\\n *    format: 'binary'\\n *  }]\\n * } & TSchema} TBinary\\n */\\n\\n/**\\n * @returns {TBinary}\\n */\\nconst Binary = () => {\\n  /**\\n   * @param {TBinary} schema\\n   * @param {unknown} value\\n   * @returns {boolean}\\n   */\\n  function BinaryCheck(schema, value) {\\n    const type = Object.prototype.toString.call(value)\\n    return (\\n      type === '[object Blob]' ||\\n      type === '[object File]' ||\\n      type === '[object String]' ||\\n      type === '[object Uint8Array]'\\n    )\\n  }\\n\\n  if (!TypeRegistry.Has('Binary')) TypeRegistry.Set('Binary', BinaryCheck)\\n\\n  return /** @type {TBinary} */ ({\\n    anyOf: [\\n      {\\n        type: 'object',\\n        additionalProperties: true\\n      },\\n      {\\n        type: 'string',\\n        format: 'binary'\\n      }\\n    ],\\n    [Kind]: 'Binary'\\n  })\\n}\\n\\nconst ComponentsSchemasDef0 = T.Object({\\n  lat: T.Number(),\\n  long: T.Number()\\n})\\nconst ComponentsSchemasDef1 = T.Array(\\n  T.Object({\\n    title: T.String(),\\n    address: T.String(),\\n    coordinates: CloneType(ComponentsSchemasDef0)\\n  })\\n)\\n\\nconst schema = {\\n  '/hello': {\\n    GET: {\\n      args: T.Void(),\\n      data: T.Any({ 'x-status-code': '200' }),\\n      error: T.Union([T.Any({ 'x-status-code': 'default' })])\\n    }\\n  },\\n  '/hello-typed': {\\n    GET: {\\n      args: T.Void(),\\n      data: T.Object(\\n        {\\n          hello: T.Boolean()\\n        },\\n        {\\n          'x-status-code': '200',\\n          'x-content-type': 'application/json'\\n        }\\n      ),\\n      error: T.Union([\\n        T.Object(\\n          {\\n            error: T.String()\\n          },\\n          {\\n            'x-status-code': '404',\\n            'x-content-type': 'application/json'\\n          }\\n        )\\n      ])\\n    }\\n  },\\n  '/multiple-content': {\\n    GET: {\\n      args: T.Void(),\\n      data: T.Object(\\n        {\\n          name: T.String()\\n        },\\n        {\\n          'x-status-code': '200',\\n          'x-content-type': 'application/json'\\n        }\\n      ),\\n      error: T.Union([\\n        T.Object(\\n          {\\n            error: T.String()\\n          },\\n          {\\n            'x-status-code': '404',\\n            'x-content-type': 'application/json'\\n          }\\n        )\\n      ])\\n    }\\n  },\\n  '/some-route/{id}': {\\n    POST: {\\n      args: T.Object({\\n        headers: T.Object({\\n          auth: T.String({ 'x-in': 'header' })\\n        }),\\n        params: T.Object({\\n          id: T.String({ 'x-in': 'path' })\\n        }),\\n        query: T.Object({\\n          filter: T.String({ 'x-in': 'query' }),\\n          address: T.Array(T.String(), { 'x-in': 'query' }),\\n          deep: T.Object(\\n            {\\n              deepTitle: T.Optional(T.String())\\n            },\\n            {\\n              'x-in': 'query'\\n            }\\n          )\\n        }),\\n        body: T.Object(\\n          {\\n            human: T.Object({\\n              name: T.String(),\\n              age: T.Optional(T.Number()),\\n              gender: T.Union([T.Literal('batman'), T.Literal('joker')])\\n            }),\\n            address: CloneType(ComponentsSchemasDef1),\\n            recursive: T.Object(\\n              {},\\n              {\\n                additionalProperties: true\\n              }\\n            )\\n          },\\n          {\\n            'x-content-type': 'application/json'\\n          }\\n        )\\n      }),\\n      data: T.Object(\\n        {\\n          params: T.Object({\\n            id: T.Optional(T.String())\\n          }),\\n          query: T.Object({\\n            filter: T.String(),\\n            address: T.Array(T.String()),\\n            deep: T.Object({\\n              deepTitle: T.String()\\n            })\\n          }),\\n          body: T.Object({\\n            human: T.Object({\\n              name: T.String(),\\n              age: T.Optional(T.Number()),\\n              gender: T.Union([T.Literal('batman'), T.Literal('joker')])\\n            }),\\n            address: CloneType(ComponentsSchemasDef1),\\n            recursive: T.Object(\\n              {},\\n              {\\n                additionalProperties: true\\n              }\\n            )\\n          })\\n        },\\n        {\\n          'x-status-code': '201',\\n          'x-content-type': 'application/json'\\n        }\\n      ),\\n      error: T.Union([T.Any({ 'x-status-code': 'default' })])\\n    }\\n  }\\n}\\n\\nconst _components = {\\n  schemas: {\\n    'def-0': CloneType(ComponentsSchemasDef0),\\n    'def-1': CloneType(ComponentsSchemasDef1)\\n  }\\n}\\n\\nexport { schema, _components as components }\\n"
 `;
 
+exports[`nullable test 1`] = `
+"/* eslint eslint-comments/no-unlimited-disable: off */\\n/* eslint-disable */\\n// This document was generated automatically by openapi-box\\n\\n/**\\n * @typedef {import('@sinclair/typebox').TSchema} TSchema\\n */\\n\\n/**\\n * @template {TSchema} T\\n * @typedef {import('@sinclair/typebox').Static<T>} Static\\n */\\n\\n/**\\n * @typedef {import('@sinclair/typebox').SchemaOptions} SchemaOptions\\n */\\n\\n/**\\n * @typedef {{\\n *  [Path in keyof typeof schema]: {\\n *    [Method in keyof typeof schema[Path]]: {\\n *      [Prop in keyof typeof schema[Path][Method]]: typeof schema[Path][Method][Prop] extends TSchema ?\\n *        Static<typeof schema[Path][Method][Prop]> :\\n *        undefined\\n *    }\\n *  }\\n * }} SchemaType\\n */\\n\\n/**\\n * @typedef {{\\n *  [ComponentType in keyof typeof _components]: {\\n *    [ComponentName in keyof typeof _components[ComponentType]]: typeof _components[ComponentType][ComponentName] extends TSchema ?\\n *      Static<typeof _components[ComponentType][ComponentName]> :\\n *      undefined\\n *  }\\n * }} ComponentType\\n */\\n\\nimport { Type as T, TypeRegistry, Kind, CloneType } from '@sinclair/typebox'\\nimport { Value } from '@sinclair/typebox/value'\\n\\n/**\\n * @typedef {{\\n *  [Kind]: 'Binary'\\n *  static: string | File | Blob | Uint8Array\\n *  anyOf: [{\\n *    type: 'object',\\n *    additionalProperties: true\\n *  }, {\\n *    type: 'string',\\n *    format: 'binary'\\n *  }]\\n * } & TSchema} TBinary\\n */\\n\\n/**\\n * @returns {TBinary}\\n */\\nconst Binary = () => {\\n  /**\\n   * @param {TBinary} schema\\n   * @param {unknown} value\\n   * @returns {boolean}\\n   */\\n  function BinaryCheck(schema, value) {\\n    const type = Object.prototype.toString.call(value)\\n    return (\\n      type === '[object Blob]' ||\\n      type === '[object File]' ||\\n      type === '[object String]' ||\\n      type === '[object Uint8Array]'\\n    )\\n  }\\n\\n  if (!TypeRegistry.Has('Binary')) TypeRegistry.Set('Binary', BinaryCheck)\\n\\n  return /** @type {TBinary} */ ({\\n    anyOf: [\\n      {\\n        type: 'object',\\n        additionalProperties: true\\n      },\\n      {\\n        type: 'string',\\n        format: 'binary'\\n      }\\n    ],\\n    [Kind]: 'Binary'\\n  })\\n}\\n\\nconst ComponentsSchemasError = T.Object({\\n  code: T.Integer({ format: 'int32' }),\\n  message: T.String()\\n})\\nconst ComponentsSchemasPet = T.Object({\\n  id: T.Integer({ format: 'int64' }),\\n  name: T.String(),\\n  tag: T.Optional(T.String())\\n})\\nconst ComponentsSchemasPets = T.Array(CloneType(ComponentsSchemasPet))\\n\\nconst schema = {\\n  '/pets': {\\n    GET: {\\n      args: T.Optional(\\n        T.Object({\\n          query: T.Optional(\\n            T.Object({\\n              limit: T.Optional(T.Integer({ format: 'int32', 'x-in': 'query' }))\\n            })\\n          )\\n        })\\n      ),\\n      data: CloneType(ComponentsSchemasPets, {\\n        'x-status-code': '200',\\n        'x-content-type': 'application/json'\\n      }),\\n      error: T.Union([\\n        CloneType(ComponentsSchemasError, {\\n          'x-status-code': 'default',\\n          'x-content-type': 'application/json'\\n        })\\n      ])\\n    },\\n    POST: {\\n      args: T.Void(),\\n      data: T.Any({ 'x-status-code': '201' }),\\n      error: T.Union([\\n        CloneType(ComponentsSchemasError, {\\n          'x-status-code': 'default',\\n          'x-content-type': 'application/json'\\n        })\\n      ])\\n    }\\n  },\\n  '/pets/{petId}': {\\n    GET: {\\n      args: T.Object({\\n        params: T.Object({\\n          petId: T.String({ 'x-in': 'path' })\\n        })\\n      }),\\n      data: CloneType(ComponentsSchemasPet, {\\n        'x-status-code': '200',\\n        'x-content-type': 'application/json'\\n      }),\\n      error: T.Union([\\n        CloneType(ComponentsSchemasError, {\\n          'x-status-code': 'default',\\n          'x-content-type': 'application/json'\\n        })\\n      ])\\n    }\\n  }\\n}\\n\\nconst _components = {\\n  parameters: {\\n    skipParam: T.Integer({ format: 'int32', 'x-in': 'query' }),\\n    limitParam: T.Integer({ format: 'int32', 'x-in': 'query' })\\n  },\\n  responses: {\\n    NotFound: T.Any({}),\\n    IllegalInput: T.Any({}),\\n    GeneralError: CloneType(ComponentsSchemasError, {\\n      'x-content-type': 'application/json'\\n    })\\n  },\\n  requestBodies: {\\n    Pet: CloneType(ComponentsSchemasPet, {\\n      'x-content-type': 'application/json'\\n    })\\n  },\\n  schemas: {\\n    Error: CloneType(ComponentsSchemasError),\\n    Pet: CloneType(ComponentsSchemasPet),\\n    Pets: CloneType(ComponentsSchemasPets)\\n  }\\n}\\n\\nexport { schema, _components as components }\\n"
+`;
+
 exports[`petstore.json 1`] = `
 "/* eslint eslint-comments/no-unlimited-disable: off */\\n/* eslint-disable */\\n// This document was generated automatically by openapi-box\\n\\n/**\\n * @typedef {import('@sinclair/typebox').TSchema} TSchema\\n */\\n\\n/**\\n * @template {TSchema} T\\n * @typedef {import('@sinclair/typebox').Static<T>} Static\\n */\\n\\n/**\\n * @typedef {import('@sinclair/typebox').SchemaOptions} SchemaOptions\\n */\\n\\n/**\\n * @typedef {{\\n *  [Path in keyof typeof schema]: {\\n *    [Method in keyof typeof schema[Path]]: {\\n *      [Prop in keyof typeof schema[Path][Method]]: typeof schema[Path][Method][Prop] extends TSchema ?\\n *        Static<typeof schema[Path][Method][Prop]> :\\n *        undefined\\n *    }\\n *  }\\n * }} SchemaType\\n */\\n\\n/**\\n * @typedef {{\\n *  [ComponentType in keyof typeof _components]: {\\n *    [ComponentName in keyof typeof _components[ComponentType]]: typeof _components[ComponentType][ComponentName] extends TSchema ?\\n *      Static<typeof _components[ComponentType][ComponentName]> :\\n *      undefined\\n *  }\\n * }} ComponentType\\n */\\n\\nimport { Type as T, TypeRegistry, Kind, CloneType } from '@sinclair/typebox'\\nimport { Value } from '@sinclair/typebox/value'\\n\\n/**\\n * @typedef {{\\n *  [Kind]: 'Binary'\\n *  static: string | File | Blob | Uint8Array\\n *  anyOf: [{\\n *    type: 'object',\\n *    additionalProperties: true\\n *  }, {\\n *    type: 'string',\\n *    format: 'binary'\\n *  }]\\n * } & TSchema} TBinary\\n */\\n\\n/**\\n * @returns {TBinary}\\n */\\nconst Binary = () => {\\n  /**\\n   * @param {TBinary} schema\\n   * @param {unknown} value\\n   * @returns {boolean}\\n   */\\n  function BinaryCheck(schema, value) {\\n    const type = Object.prototype.toString.call(value)\\n    return (\\n      type === '[object Blob]' ||\\n      type === '[object File]' ||\\n      type === '[object String]' ||\\n      type === '[object Uint8Array]'\\n    )\\n  }\\n\\n  if (!TypeRegistry.Has('Binary')) TypeRegistry.Set('Binary', BinaryCheck)\\n\\n  return /** @type {TBinary} */ ({\\n    anyOf: [\\n      {\\n        type: 'object',\\n        additionalProperties: true\\n      },\\n      {\\n        type: 'string',\\n        format: 'binary'\\n      }\\n    ],\\n    [Kind]: 'Binary'\\n  })\\n}\\n\\nconst ComponentsSchemasError = T.Object({\\n  code: T.Integer({ format: 'int32' }),\\n  message: T.String()\\n})\\nconst ComponentsSchemasPet = T.Object({\\n  id: T.Integer({ format: 'int64' }),\\n  name: T.String(),\\n  tag: T.Optional(T.String())\\n})\\nconst ComponentsSchemasPets = T.Array(CloneType(ComponentsSchemasPet))\\n\\nconst schema = {\\n  '/pets': {\\n    GET: {\\n      args: T.Optional(\\n        T.Object({\\n          query: T.Optional(\\n            T.Object({\\n              limit: T.Optional(T.Integer({ format: 'int32', 'x-in': 'query' }))\\n            })\\n          )\\n        })\\n      ),\\n      data: CloneType(ComponentsSchemasPets, {\\n        'x-status-code': '200',\\n        'x-content-type': 'application/json'\\n      }),\\n      error: T.Union([\\n        CloneType(ComponentsSchemasError, {\\n          'x-status-code': 'default',\\n          'x-content-type': 'application/json'\\n        })\\n      ])\\n    },\\n    POST: {\\n      args: T.Void(),\\n      data: T.Any({ 'x-status-code': '201' }),\\n      error: T.Union([\\n        CloneType(ComponentsSchemasError, {\\n          'x-status-code': 'default',\\n          'x-content-type': 'application/json'\\n        })\\n      ])\\n    }\\n  },\\n  '/pets/{petId}': {\\n    GET: {\\n      args: T.Object({\\n        params: T.Object({\\n          petId: T.String({ 'x-in': 'path' })\\n        })\\n      }),\\n      data: CloneType(ComponentsSchemasPet, {\\n        'x-status-code': '200',\\n        'x-content-type': 'application/json'\\n      }),\\n      error: T.Union([\\n        CloneType(ComponentsSchemasError, {\\n          'x-status-code': 'default',\\n          'x-content-type': 'application/json'\\n        })\\n      ])\\n    }\\n  }\\n}\\n\\nconst _components = {\\n  parameters: {\\n    skipParam: T.Integer({ format: 'int32', 'x-in': 'query' }),\\n    limitParam: T.Integer({ format: 'int32', 'x-in': 'query' })\\n  },\\n  responses: {\\n    NotFound: T.Any({}),\\n    IllegalInput: T.Any({}),\\n    GeneralError: CloneType(ComponentsSchemasError, {\\n      'x-content-type': 'application/json'\\n    })\\n  },\\n  requestBodies: {\\n    Pet: CloneType(ComponentsSchemasPet, {\\n      'x-content-type': 'application/json'\\n    })\\n  },\\n  schemas: {\\n    Error: CloneType(ComponentsSchemasError),\\n    Pet: CloneType(ComponentsSchemasPet),\\n    Pets: CloneType(ComponentsSchemasPets)\\n  }\\n}\\n\\nexport { schema, _components as components }\\n"
 `;
diff --git a/test/test-nullable.yaml b/test/test-nullable.yaml
new file mode 100644
index 0000000..7f252c9
--- /dev/null
+++ b/test/test-nullable.yaml
@@ -0,0 +1,27 @@
+openapi: 3.0.3
+info:
+  description: Title
+  version: 1.0.0
+servers:
+  - url: https
+
+components:
+  schemas:
+    Test:
+      type: object
+      nullable: true
+      required:
+        - testArr
+      properties:
+        testStr:
+          type: string
+          description: Should be a 2 character string, null or undefined
+          minLength: 2
+          maxLength: 2
+          nullable: true
+        testArr:
+          type: array
+          description: Should be an array of numbers or null
+          items:
+            type: number
+          nullable: true