Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions spec/types/never.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { z } from 'zod';
import { expectSchema } from '../lib/helpers';

describe('never', () => {
describe('in object properties', () => {
it('skips never properties in objects', () => {
const schema = z
.object({
id: z.string(),
impossible: z.never(),
name: z.string(),
})
.openapi('TestObject');

expectSchema([schema], {
TestObject: {
type: 'object',
properties: {
id: {
type: 'string',
},
name: {
type: 'string',
},
},
required: ['id', 'name'],
},
});
});

it('does not mark never as required', () => {
const schema = z
.object({
id: z.string(),
impossible: z.never(),
})
.openapi('TestObject');

expectSchema([schema], {
TestObject: {
type: 'object',
properties: {
id: {
type: 'string',
},
},
required: ['id'],
},
});
});

it('skips optional never properties', () => {
const schema = z
.object({
id: z.string(),
impossible: z.never().optional(),
})
.openapi('TestObject');

expectSchema([schema], {
TestObject: {
type: 'object',
properties: {
id: {
type: 'string',
},
},
required: ['id'],
},
});
});
});

describe('as direct schema', () => {
it('allows z.never() as direct schema - returns empty schema', () => {
const schema = z.never().openapi('DirectNever');

expectSchema([schema], {
DirectNever: {},
});
});

it('returns empty schema for optional never at top level', () => {
const schema = z.never().optional().openapi('OptionalNever');

expectSchema([schema], {
OptionalNever: {},
});
});

it('returns empty schema for nullable never at top level', () => {
const schema = z.never().nullable().openapi('NullableNever');

expectSchema([schema], {
NullableNever: {},
});
});
});

describe('in arrays', () => {
it('allows z.never() in array - returns array with no items constraint', () => {
const schema = z.array(z.never()).openapi('NeverArray');

expectSchema([schema], {
NeverArray: {
type: 'array',
items: {},
},
});
});
});

describe('in unions', () => {
it('filters never from union - only keeps non-never types', () => {
const schema = z.union([z.string(), z.never()]).openapi('StringOrNever');

expectSchema([schema], {
StringOrNever: {
type: 'string',
},
});
});

it('filters never from union with multiple types', () => {
const schema = z
.union([z.string(), z.number(), z.never()])
.openapi('StringNumberOrNever');

expectSchema([schema], {
StringNumberOrNever: {
anyOf: [{ type: 'string' }, { type: 'number' }],
},
});
});

it('returns empty schema for union with only never types', () => {
const schema = z.union([z.never(), z.never()]).openapi('OnlyNever');

expectSchema([schema], {
OnlyNever: {},
});
});
});
});
146 changes: 146 additions & 0 deletions spec/types/void.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { z } from 'zod';
import { expectSchema } from '../lib/helpers';

describe('void', () => {
describe('in object properties', () => {
it('skips void properties in objects', () => {
const schema = z
.object({
id: z.string(),
unused: z.void(),
name: z.string(),
})
.openapi('TestObject');

expectSchema([schema], {
TestObject: {
type: 'object',
properties: {
id: {
type: 'string',
},
name: {
type: 'string',
},
},
required: ['id', 'name'],
},
});
});

it('does not mark void as required', () => {
const schema = z
.object({
id: z.string(),
unused: z.void(),
})
.openapi('TestObject');

expectSchema([schema], {
TestObject: {
type: 'object',
properties: {
id: {
type: 'string',
},
},
required: ['id'],
},
});
});

it('skips optional void properties', () => {
const schema = z
.object({
id: z.string(),
unused: z.void().optional(),
})
.openapi('TestObject');

expectSchema([schema], {
TestObject: {
type: 'object',
properties: {
id: {
type: 'string',
},
},
required: ['id'],
},
});
});
});

describe('as direct schema', () => {
it('allows z.void() as direct schema - returns empty schema', () => {
const schema = z.void().openapi('DirectVoid');

expectSchema([schema], {
DirectVoid: {},
});
});

it('returns empty schema for optional void at top level', () => {
const schema = z.void().optional().openapi('OptionalVoid');

expectSchema([schema], {
OptionalVoid: {},
});
});

it('returns empty schema for nullable void at top level', () => {
const schema = z.void().nullable().openapi('NullableVoid');

expectSchema([schema], {
NullableVoid: {},
});
});
});

describe('in arrays', () => {
it('allows z.void() in array - returns array with no items constraint', () => {
const schema = z.array(z.void()).openapi('VoidArray');

expectSchema([schema], {
VoidArray: {
type: 'array',
items: {},
},
});
});
});

describe('in unions', () => {
it('filters void from union - only keeps non-void types', () => {
const schema = z.union([z.string(), z.void()]).openapi('StringOrVoid');

expectSchema([schema], {
StringOrVoid: {
type: 'string',
},
});
});

it('filters void from union with multiple types', () => {
const schema = z
.union([z.string(), z.number(), z.void()])
.openapi('StringNumberOrVoid');

expectSchema([schema], {
StringNumberOrVoid: {
anyOf: [{ type: 'string' }, { type: 'number' }],
},
});
});

it('returns empty schema for union with only void types', () => {
const schema = z.union([z.void(), z.void()]).openapi('OnlyVoid');

expectSchema([schema], {
OnlyVoid: {},
});
});
});
});


7 changes: 7 additions & 0 deletions src/lib/zod-is-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,13 @@ export function isAnyZodType(schema: object): schema is z.ZodType {
return 'def' in schema;
}

/**
* Checks if a schema is void or never, which should be skipped
* since they don't contribute meaningful OpenAPI constraints
*/
export function isSkippableZodType(schema: z.ZodType): boolean {
return isZodType(schema, ['ZodVoid', 'ZodNever']);
}
/**
* The schema.isNullable() is deprecated. This is the suggested replacement
* as this was how isNullable operated beforehand.
Expand Down
8 changes: 6 additions & 2 deletions src/transformers/array.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { ZodArray } from 'zod';
import { MapNullableType, MapSubSchema } from '../types';
import { $ZodCheckMinLength, $ZodCheckMaxLength } from 'zod/core';
import { isAnyZodType } from '../lib/zod-is-type';
import { isAnyZodType, isSkippableZodType } from '../lib/zod-is-type';

export class ArrayTransformer {
transform(
zodSchema: ZodArray,
Expand All @@ -22,7 +23,10 @@ export class ArrayTransformer {

return {
...mapNullableType('array'),
items: isAnyZodType(itemType) ? mapItems(itemType) : {},
items:
isAnyZodType(itemType) && !isSkippableZodType(zodSchema)
? mapItems(itemType)
: {},

minItems,
maxItems,
Expand Down
6 changes: 5 additions & 1 deletion src/transformers/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { SchemaObject, ReferenceObject, MapSubSchema } from '../types';
import { ZodType } from 'zod';
import { UnknownZodTypeError } from '../errors';
import { isZodType } from '../lib/zod-is-type';
import { isSkippableZodType, isZodType } from '../lib/zod-is-type';
import { Metadata } from '../metadata';
import { ArrayTransformer } from './array';
import { BigIntTransformer } from './big-int';
Expand Down Expand Up @@ -51,6 +51,10 @@ export class OpenApiTransformer {
generateSchemaRef: (ref: string) => string,
defaultValue?: T
): SchemaObject | ReferenceObject {
if (isSkippableZodType(zodSchema)) {
return {};
}

if (isZodType(zodSchema, 'ZodNull')) {
return this.versionSpecifics.nullType;
}
Expand Down
23 changes: 18 additions & 5 deletions src/transformers/object.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { MapNullableType, MapSubSchema, SchemaObject } from '../types';
import { ZodObject } from 'zod';
import { isAnyZodType, isOptionalSchema, isZodType } from '../lib/zod-is-type';
import { mapValues, objectEquals } from '../lib/lodash';
import {
isAnyZodType,
isOptionalSchema,
isSkippableZodType,
isZodType,
} from '../lib/zod-is-type';
import { mapValues, objectEquals, omitBy } from '../lib/lodash';
import { Metadata } from '../metadata';

export class ObjectTransformer {
Expand All @@ -14,7 +19,10 @@ export class ObjectTransformer {
const extendedFrom = Metadata.getInternalMetadata(zodSchema)?.extendedFrom;

const required = this.requiredKeysOf(zodSchema);
const properties = mapValues(zodSchema.def.shape, mapItem);
const shape = omitBy(zodSchema.def.shape, type =>
isSkippableZodType(Metadata.unwrapChained(type))
);
const properties = mapValues(shape, mapItem);

if (!extendedFrom) {
return {
Expand All @@ -35,7 +43,10 @@ export class ObjectTransformer {
mapItem(parent);

const keysRequiredByParent = this.requiredKeysOf(parent);
const propsOfParent = mapValues(parent?.def.shape, mapItem);
const parentShape = omitBy(parent?.def.shape, type =>
isSkippableZodType(Metadata.unwrapChained(type))
);
const propsOfParent = mapValues(parentShape, mapItem);

const propertiesToAdd = Object.fromEntries(
Object.entries(properties).filter(([key, type]) => {
Expand Down Expand Up @@ -90,7 +101,9 @@ export class ObjectTransformer {

private requiredKeysOf(objectSchema: ZodObject) {
return Object.entries(objectSchema.def.shape)
.filter(([_key, type]) => !isOptionalSchema(type))
.filter(
([_key, type]) => !isOptionalSchema(type) && !isSkippableZodType(type)
)
.map(([key, _type]) => key);
}
}
Loading