colinhacks / zod

TypeScript-first schema validation with static type inference
https://zod.dev
MIT License
32.94k stars 1.14k forks source link

`z.coerce.object()` should be a thing #2786

Open alexgleason opened 11 months ago

alexgleason commented 11 months ago

Instead of:

const mrfSimpleSchema = z.object({
  accept: z.string().array().catch([]),
  avatar_removal: z.string().array().catch([]),
  banner_removal: z.string().array().catch([]),
  federated_timeline_removal: z.string().array().catch([]),
  followers_only: z.string().array().catch([]),
  media_nsfw: z.string().array().catch([]),
  media_removal: z.string().array().catch([]),
  reject: z.string().array().catch([]),
  reject_deletes: z.string().array().catch([]),
  report_removal: z.string().array().catch([]),
}).catch({
  accept: [],
  avatar_removal: [],
  banner_removal: [],
  federated_timeline_removal: [],
  followers_only: [],
  media_nsfw: [],
  media_removal: [],
  reject: [],
  reject_deletes: [],
  report_removal: [],
});

We should be able to:

const mrfSimpleSchema = z.coerce.object({
  accept: z.string().array().catch([]),
  avatar_removal: z.string().array().catch([]),
  banner_removal: z.string().array().catch([]),
  federated_timeline_removal: z.string().array().catch([]),
  followers_only: z.string().array().catch([]),
  media_nsfw: z.string().array().catch([]),
  media_removal: z.string().array().catch([]),
  reject: z.string().array().catch([]),
  reject_deletes: z.string().array().catch([]),
  report_removal: z.string().array().catch([]),
});

z.coerce.object would be roughly equivalent to:

z.object({}).passthrough().catch({}).pipe(myObjSchema)

All keys of the shape need to be a ZodCatch or ZodOptional, otherwise it's a TypeError.

alexgleason commented 11 months ago

This helper function is doing what I want:

/** zod schema to force the value into an object, if it isn't already. */
function coerceObject<T extends z.ZodRawShape>(shape: T) {
  return z.object({}).passthrough().catch({}).pipe(z.object(shape));
}

Now instead of z.object({ ...shape }) I use coerceObject({ ...shape }).

Resulting in this behavior:

const configurationSchema = coerceObject({
  chats: coerceObject({
    max_characters: z.number().catch(5000),
    max_media_attachments: z.number().catch(1),
  }),
  groups: coerceObject({
    max_characters_description: z.number().catch(160),
    max_characters_name: z.number().catch(50),
  }),
  media_attachments: coerceObject({
    image_matrix_limit: z.number().optional().catch(undefined),
    image_size_limit: z.number().optional().catch(undefined),
    supported_mime_types: mimeSchema.array().optional().catch(undefined),
    video_duration_limit: z.number().optional().catch(undefined),
    video_frame_rate_limit: z.number().optional().catch(undefined),
    video_matrix_limit: z.number().optional().catch(undefined),
    video_size_limit: z.number().optional().catch(undefined),
  }),
  polls: coerceObject({
    max_characters_per_option: z.number().catch(25),
    max_expiration: z.number().catch(2629746),
    max_options: z.number().catch(4),
    min_expiration: z.number().catch(300),
  }),
  statuses: coerceObject({
    max_characters: z.number().catch(500),
    max_media_attachments: z.number().catch(4),
  }),
});

configurationSchema.parse({}) // { chats: { max_characters: 5000, ... }, ... }

What do you think @colinhacks, should this belong in the main library? It was one of my biggest issues with zod until I learned how to get good at it.

It's especially useful when you have deeply-nested data and you cannot guarantee certain deep values exist. Some APIs really do this! Eg: https://mastodon.social/api/v1/instance Between different Mastodon servers I have no clue what fields will be available.

aaronadamsCA commented 1 month ago

@alexgleason, I think the helper below should work the same as yours; the one benefit is a narrower return type that works with z.input<typeof schema> type inference, which was the one additional thing we needed for our codebase.

function coerceObject<T extends z.ZodRawShape>(
  shape: T,
  params?: z.RawCreateParams,
) {
  return new z.ZodEffects({
    schema: z.object(shape, params),
    effect: { type: "preprocess", transform: Object },
    typeName: z.ZodFirstPartyTypeKind.ZodEffects,
  });
}

This is basically z.preprocess(Object, z.object(shape)), just without forcing the input type to unknown.

Here it is with an explicit function return type:

```ts function coerceObject( shape: T, params?: z.RawCreateParams, ): z.ZodEffects< z.ZodObject< T, "strip", z.ZodTypeAny, z.objectOutputType, z.objectInputType >, z.objectOutputType, z.objectInputType > { return new z.ZodEffects({ schema: z.object(shape, params), effect: { type: "preprocess", transform: Object }, typeName: z.ZodFirstPartyTypeKind.ZodEffects, }); } ```

Unfortunately it still doesn't return a ZodObject, so you still can't .extend or .merge it. I tried to overcome this by subclassing ZodObject subclass to override its _parse method, but this didn't pan out.