Repository: samchungy/zod-openapi
Pull request #350 (2024-10-28 22:07:59)

Add support for non optional custom zod types

samchungy: This looks okay to me, I'll have another look when I have time to properly suss it out. 

But would `z.coerce.date()` be more appropriate in your scenario?
pmsfc: > This looks okay to me, I'll have another look when I have time to properly suss it out.
> 
> But would `z.coerce.date()` be more appropriate in your scenario?

I'm using LocalDates from js-joda lib, just used Date as an example here.
pmsfc: > Hmmm I'm not entirely sure I like the approach of treating a custom as non optional by default given it can potentially return anything. Including undefined which in JavaScript world is also optional

This solution won't do that because it's still calling zod's `isOptional()`, for example:
```typescript
it('returns true for a custom undefined without a check', () => {
  const schema = z.custom<undefined>();
  const result = isOptionalSchema(schema, createOutputState());

  expect(result).toEqual({ optional: true });
});

it('returns true for a custom undefined with a check', () => {
  const schema = z.custom<undefined>((_: unknown) => false);
  const result = isOptionalSchema(schema, createOutputState());

  expect(result).toEqual({ optional: false });
});
```
For ex both of these will pass because zod will call the the safeParse.success when calling isOptional()
samchungy: You could also return something | undefined though in a custom.
pmsfc: > You could also return something | undefined though in a custom.

From my understanding (I may be wrong) the type you pass to the custom won't matter. If you don't provide a `check` the `schema.isOptional()` will return true for custom types. But if you provide a check it won't be.

For custom of `string | undefined` the following tests pass
```typescript
  it('returns false for a custom string | undefined without a check', () => {
    const schema = z.custom<string | undefined>();
    const result = isOptionalSchema(schema, createOutputState());

    expect(result).toEqual({ optional: true });
  });

  it('returns false for a custom string | undefined with a check that returns false for undefined', () => {
    const schema = z.custom<string | undefined>(
      (toCheck: string | undefined) => toCheck !== undefined,
    );
    const result = isOptionalSchema(schema, createOutputState());

    expect(result).toEqual({ optional: false });
  });

  it('returns true for a custom string | undefined with a check that returns true for undefined', () => {
    const schema = z.custom<string | undefined>(
      (toCheck: string | undefined) => toCheck === undefined,
    );
    const result = isOptionalSchema(schema, createOutputState());

    expect(result).toEqual({ optional: true });
  });
```
samchungy: Ah awesome. TIL: https://github.com/colinhacks/zod/blob/1f4f0dacf313a2dba45563d78171e6f016096925/src/types.ts#L515
samchungy: Thank you for your work looking into this! I will take a final look tomorrow 🙂
samchungy: Thanks for this, I might end up re-writing all of the optional checking logic though but I'll keep your test cases

Patch:
diff --git a/src/create/schema/parsers/optional.ts b/src/create/schema/parsers/optional.ts
index aa23843..57c76b1 100644
--- a/src/create/schema/parsers/optional.ts
+++ b/src/create/schema/parsers/optional.ts
@@ -57,6 +57,11 @@ export const isOptionalSchema = (
   }
 
   if (isZodType(zodSchema, 'ZodEffects')) {
+    // zod custom types
+    if (isZodType(zodSchema._def.schema, 'ZodAny')) {
+      return { optional: zodSchema.isOptional() };
+    }
+
     return isOptionalSchema(zodSchema._def.schema, state);
   }
 


Test patch:
diff --git a/src/create/schema/parsers/object.test.ts b/src/create/schema/parsers/object.test.ts
index 37d2701..496a3f9 100644
--- a/src/create/schema/parsers/object.test.ts
+++ b/src/create/schema/parsers/object.test.ts
@@ -287,4 +287,41 @@ describe('extend', () => {
 
     expect(result).toEqual(expected);
   });
+
+  it('creates an object with 2 required fields using a custom type', () => {
+    const expected: Schema = {
+      type: 'schema',
+      schema: {
+        type: 'object',
+        properties: {
+          a: {
+            type: 'string',
+          },
+          b: {
+            type: 'string',
+            format: 'date',
+          },
+        },
+        required: ['a', 'b'],
+      },
+    };
+    const zodDate = z
+      .union([
+        z.custom<Date>((val: unknown) => val instanceof Date),
+        z.string().transform((str: string): Date => new Date(str)), // ignore validation
+      ])
+      .openapi({
+        type: 'string',
+        format: 'date',
+      });
+
+    const schema = z.object({
+      a: z.string(),
+      b: zodDate,
+    });
+
+    const result = createObjectSchema(schema, createOutputState());
+
+    expect(result).toEqual(expected);
+  });
 });
diff --git a/src/create/schema/parsers/optional.test.ts b/src/create/schema/parsers/optional.test.ts
index 97f257c..c761503 100644
--- a/src/create/schema/parsers/optional.test.ts
+++ b/src/create/schema/parsers/optional.test.ts
@@ -78,6 +78,91 @@ describe('isOptionalSchema', () => {
     expect(result).toEqual({ optional: true });
   });
 
+  it('returns false for an async transform without an optional', () => {
+    const schema = z
+      .string()
+      // eslint-disable-next-line @typescript-eslint/require-await
+      .transform(async () => 'x');
+
+    const result = isOptionalSchema(schema, createOutputState());
+
+    expect(result).toEqual({ optional: false });
+  });
+
+  it('returns false for a custom without an optional', () => {
+    const schema = z.custom<Date>((d) => d instanceof Date);
+    const result = isOptionalSchema(schema, createOutputState());
+
+    expect(result).toEqual({ optional: false });
+  });
+
+  it('returns true for a custom undefined without a check', () => {
+    const schema = z.custom<undefined>();
+    const result = isOptionalSchema(schema, createOutputState());
+
+    expect(result).toEqual({ optional: true });
+  });
+
+  it('returns false for a custom string | undefined without a check', () => {
+    const schema = z.custom<string | undefined>();
+    const result = isOptionalSchema(schema, createOutputState());
+
+    expect(result).toEqual({ optional: true });
+  });
+
+  it('returns false for a custom string | undefined with a check that returns false for undefined', () => {
+    const schema = z.custom<string | undefined>(
+      (toCheck: string | undefined) => toCheck !== undefined,
+    );
+    const result = isOptionalSchema(schema, createOutputState());
+
+    expect(result).toEqual({ optional: false });
+  });
+
+  it('returns true for a custom string | undefined with a check that returns true for undefined', () => {
+    const schema = z.custom<string | undefined>(
+      (toCheck: string | undefined) => toCheck === undefined,
+    );
+    const result = isOptionalSchema(schema, createOutputState());
+
+    expect(result).toEqual({ optional: true });
+  });
+
+  it('returns true for a custom undefined with a check', () => {
+    const schema = z.custom<undefined>((_: unknown) => false);
+    const result = isOptionalSchema(schema, createOutputState());
+
+    expect(result).toEqual({ optional: false });
+  });
+
+  it('returns true for a custom with Date with an optional', () => {
+    const schema = z.custom<Date>((d) => d instanceof Date).optional();
+    const result = isOptionalSchema(schema, createOutputState());
+
+    expect(result).toEqual({ optional: true });
+  });
+
+  it('returns false for a custom without an optional with async transform', () => {
+    const schema = z
+      .custom<Date>((d) => d instanceof Date)
+      // eslint-disable-next-line @typescript-eslint/require-await
+      .transform(async () => 'x');
+    const result = isOptionalSchema(schema, createOutputState());
+
+    expect(result).toEqual({ optional: false });
+  });
+
+  it('returns true for a custom with an optional using a transform', () => {
+    const schema = z
+      .custom<Date>((d) => d instanceof Date)
+      .optional()
+      // eslint-disable-next-line @typescript-eslint/require-await
+      .transform(async () => 'x');
+    const result = isOptionalSchema(schema, createOutputState());
+
+    expect(result).toEqual({ optional: true });
+  });
+
   it('returns true for an output state on pipeline', () => {
     const schema = z
       .string()