zod icon indicating copy to clipboard operation
zod copied to clipboard

`z.object` to be mergeable with `z.record`, or ability to define keys by a custom type

Open eturino opened this issue 1 year ago • 11 comments

I have a type that's basically like this

type YKeys = `y${number}`;
type ZKeys = `z${number}`;
type DataKeys = `d${number}`;

type Point = {
  x: string;
  id: string;
} & Record<YKeys, number> & Record<ZKeys, number> & Record<DataKeys, { stuff: string }>

I have the schemas for the special keys

const yKeysSchema = z.custom<`i${number}`>((val) => /^y(\d+)$/.test(val as string));
const zKeysSchema = z.custom<`i${number}`>((val) => /^z(\d+)$/.test(val as string));
const dataKeysSchema = z.custom<`i${number}`>((val) => /^d(\d+)$/.test(val as string));

and I used to be able to get by with just a superRefine call to a pointValidityRefinement function that will check the keys, with something like

const pointSchema = z.object({ x: z.string(), id: z.string() }).passthrough().superRefine(pointValidityRefinement);

But since the last update I get errors like

Type 'objectOutputType<{ x: ZodNumber; id: ZodString; }, ZodTypeAny, "passthrough">' is not assignable to type 'Point'.
    'string' and '`y${number}`' index signatures are incompatible.
      Type 'unknown' is not assignable to type 'number | undefined'.

I tried to change the schema of the point to something like

const pointSchema = z.object({ x: z.string(), id: z.string() })
  .merge(z.record(yKeysSchema, z.number())
  .merge(z.record(zKeysSchema, z.number())
  .merge(z.record(dataKeysSchema, z.object({ stuff: z.string() }))

but that doesn't work.

Is there any way I can do this?

btw, I'm using TypeScript 4.8 and latest zod (3.21.4)

eturino avatar Mar 15 '23 18:03 eturino

I completely agree with merging a record and object in this way. I am currently ignoring zod types for certain types when it would be nice to be able to use zod.

I have some enums that make sense to be enums as they are used in forms this way.

import { z } from "zod";

export const PROPS = ["foo", "bar", "baz"] as const;
export const ZProp = z.enum(PROPS);
export type Prop = z.infer<typeof ZProp>;

export const VALUES = ["this", "that", "another"] as const;
export const ZValue = z.enum(VALUES);
export type Value = z.infer<typeof ZValue>;

Then I create a record for them.

export const ZStatRecord = z.record(ZProp, ZValue);
export type StatRecord = z.infer<typeof ZStatRecord>;

At the moment I am creating my object like this, not with zod

export const ZMyObjectWithoutProps = z.object({
  name: z.string(),
  age: z.number(),
});

export type MyObjectWithoutProps = z.infer<typeof ZMyObjectWithoutProps>;
export type MyObject = Required<MyObjectWithoutProps> & Required<StatRecord>;

When I feel this would be much better

export const ZMyObject = z
  .object({
    name: z.string(),
    age: z.number(),
  })
  .merge(ZStatRecord);

I am aware that in this case the data structure could be argued to be better and have the record as a property of MyObject, but I still feel this is valid.

DanTheOrange avatar Apr 20 '23 12:04 DanTheOrange

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

stale[bot] avatar Jul 19 '23 18:07 stale[bot]

any way of making this happen?

eturino avatar Jul 19 '23 20:07 eturino

I need the same thing for my API. I have a number of dynamic fields that are all prefixed with the same string.

hansoksendahl avatar Aug 31 '23 08:08 hansoksendahl

@eturino It looks like you can get the functionality you are after through catchAll https://github.com/colinhacks/zod/issues/1333

hansoksendahl avatar Aug 31 '23 08:08 hansoksendahl

@eturino It looks like you can get the functionality you are after through catchAll #1333

I don't think this is quite the same issue. catchall does not allow you to specify a key type (i.e., you can't use a template literal key).

robere2 avatar Oct 12 '23 19:10 robere2

Any news on this one?

eturino avatar Nov 23 '23 20:11 eturino

I had this problem and managed a workaround using passthrough and chaining refine.

It is not the cleanest way, regarding errors in particular, as it is stated in the doc that refine callback should return a boolean rather than throw. And I think this would mean that to have an error message as explicit you would need to rather use a superRefine to be able to pass the key in your message. But I have to admit I don't really know what is the downside, as I am returning an explicit message and always wrapping my validation in try / catch.

I am a rookie so I don't know how bad it is performance wise as I am re-checking each key , but this is a personal project for me to learn, I am not expecting 1 trillion request per second.

Hope it helps sone 🤷‍♂️

const literalKeys = z
    .object({
      name: z.string(),
      id: z.number(),
    })
    .passthrough()

    .refine((data) => {      
      for (const key in data) {
        key.includes("lastname") &&          
           z
            .string({
              invalid_type_error: `${key} expects a string, received a ${typeof data[key]} : ${data[key]}`,
            })
            .parse(data[key]);

        key.includes("age") &&          
           z
            .number({
              invalid_type_error: `${key} expects a number, received a ${typeof data[key]} : ${data[key]}`,
            })
            .parse(data[key]);

      }
      return true;
    })

ZacBouh avatar Mar 18 '24 23:03 ZacBouh

I am surprised that this is not possible. It is so simple to express in TypeScript.

mi-na-bot avatar Mar 26 '24 00:03 mi-na-bot

Hi guys. I use intersection for this.

To Record with optional values

const ZRecordKeys = z.enum(['recordKey1', 'recordKey2', 'recordKey3']) 
const ZRecordExample = z.record(ZRecordKeys, z.string())

const ZObjectExample = z.object({
  objectKey1: z.number(),
  objectKey2: z.string(),
  objectKey3: z.number(),
})

const ZObjectRecord = z.intersection(ZRecordExample, ZObjectExample)

export type TObjectRecord = z.infer<typeof ZObjectRecord>

To Record with required values

const ZRecordKeys = z.enum(['recordKey1', 'recordKey2', 'recordKey3'])
const ZRecordExample = z
  .record(ZRecordKeys, z.string())
  .refine((obj): obj is Required<typeof obj> =>
    ZRecordKeys.options.every((key) => obj[key] !== null),
  )

const ZObjectExample = z.object({
  objectKey1: z.number(),
  objectKey2: z.string(),
  objectKey3: z.number(),
})

const ZObjectRecord = z.intersection(ZRecordExample, ZObjectExample)

export type TObjectRecord = z.infer<typeof ZObjectRecord>

This is the result for optional values Captura de Tela 2024-08-21 às 11 30 11 Captura de Tela 2024-08-21 às 11 18 47

This is the result for required values Captura de Tela 2024-08-21 às 11 30 34 Captura de Tela 2024-08-21 às 11 19 37

The single prompt that you must specify which Record keys this way it is possible to overwrite Record properties normally if necessary

alexmadeira avatar Aug 21 '24 14:08 alexmadeira

@alexmadeira I haven't been able to make your suggestion work, the parsing fails, even when TS is happy about it.

import { z } from "zod";

describe("trying it out", () => {
  const ZRecordKeys = z.enum(["recordKey1", "recordKey2", "recordKey3"]);
  const ZRecordExample = z.record(ZRecordKeys, z.string());

  const ZObjectExample = z.object({
    objectKey1: z.number(),
    objectKey2: z.string(),
    objectKey3: z.number(),
  });

  const ZObjectRecord = z.intersection(ZRecordExample, ZObjectExample);

  type TObjectRecord = z.infer<typeof ZObjectRecord>;
  it("should work", () => {
    const typedObject = ZObjectRecord.parse({
      objectKey1: 1,
      objectKey2: "a",
      objectKey3: 3,
    });
  });
});

fails with

ZodError: [
  {
    "received": "objectKey1",
    "code": "invalid_enum_value",
    "options": [
      "recordKey1",
      "recordKey2",
      "recordKey3"
    ],
    "path": [
      "objectKey1"
    ],
    "message": "Invalid enum value. Expected 'recordKey1' | 'recordKey2' | 'recordKey3', received 'objectKey1'"
  },
  {
    "code": "invalid_type",
    "expected": "string",
    "received": "number",
    "path": [
      "objectKey1"
    ],
    "message": "Expected string, received number"
  },
  {
    "received": "objectKey2",
    "code": "invalid_enum_value",
    "options": [
      "recordKey1",
      "recordKey2",
      "recordKey3"
    ],
    "path": [
      "objectKey2"
    ],
    "message": "Invalid enum value. Expected 'recordKey1' | 'recordKey2' | 'recordKey3', received 'objectKey2'"
  },
  {
    "received": "objectKey3",
    "code": "invalid_enum_value",
    "options": [
      "recordKey1",
      "recordKey2",
      "recordKey3"
    ],
    "path": [
      "objectKey3"
    ],
    "message": "Invalid enum value. Expected 'recordKey1' | 'recordKey2' | 'recordKey3', received 'objectKey3'"
  },
  {
    "code": "invalid_type",
    "expected": "string",
    "received": "number",
    "path": [
      "objectKey3"
    ],
    "message": "Expected string, received number"
  }
]


eturino avatar Aug 22 '24 11:08 eturino