colinhacks / zod

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

intersection between `object()` and `record()` parsing fails #2195

Open nassero opened 1 year ago

nassero commented 1 year ago

I have an intersection between a z.object() and a z.record() that has a refined key. Parsing an input fails since it seems to require all the input keys to match the record and the object, despite typescript seeing it as valid against the type.

version: 3.20.6

Example:

type FooKey = `foo.${string}`
const isValidFooKey = (key: string): key is FooKey => key.startsWith("foo.")
const Foo = z.record(
  z.string().refine(isValidFooKey),
  z.string(),
)
type Foo = z.infer<typeof Foo> // type Foo = { [x: `foo.${string}`]: string | undefined; }

console.log(Foo.parse({
  "foo.x": "some string value",
})) // succeeds, output: `{ 'foo.x': 'some string value' }`

const Bar = z.object({
  bar: z.string().optional(),
})
type Bar = z.infer<typeof Bar> // type Bar = { bar: string | undefined; }

console.log(Bar.parse({
  bar: "another string value",
})) // succeeds, output: `{ bar: 'another string value' }`

const BarAndFoo = Bar.and(Foo)
type BarAndFoo = z.infer<typeof BarAndFoo> // type BarAndFoo = { bar: string | undefined; } & Partial<Record<`foo.${string}`, string>>

console.log(BarAndFoo.parse({
  "foo.x": "some string value",
})) // succeeds, output: `{ 'foo.x': 'some string value' }`

console.log(BarAndFoo.parse({
  bar: "another string value",
})) /* fails with: ```ZodError: [
  {
    "code": "custom",
    "message": "Invalid input",
    "path": [
      "bar"
    ]
  }
]``` */

console.log(BarAndFoo.parse({
  "foo.x": "some string value",
  bar: "another string value",
})) /* fails with: ```ZodError: [
  {
    "code": "custom",
    "message": "Invalid input",
    "path": [
      "bar"
    ]
  }
]``` */

const x: BarAndFoo = {
  "foo.x": "some string value",
  bar: "another string value",
} // typescript is happy with this
flevi29 commented 1 year ago

I also just ran into this issue. intersection in combination with record seems to be flawed. The following code shouldn't even be accepted by typescript, but more importantly it just fails no matter what I pass to it.

const first = z.record(
  z.union([z.literal("one"), z.literal("two"), z.literal("three")]),
  z.string(),
);
const second = z.record(
  z.union([z.literal("ones"), z.literal("twos"), z.literal("threes")]),
  z.string().array(),
);
const intersect = z.intersection(first, second);

console.log(intersect.parse({
  one: ["example"],
}));
avallete commented 1 year ago

Just ran into the same issue with this type:


export interface Transform {
  [schema: string]: {
    [table: string]: boolean | RowTransform<RowShape>
  }
}
const transformConfigOptionsSchema = z.object({
  $mode: z
    .union([z.literal('auto'), z.literal('strict'), z.literal('unsafe')])
    .optional(),
  $parseJson: z.boolean().optional(),
})
const transformConfigTableSchema = z.record(
  z.string().describe('table'),
  z.union([
    z
      .function()
      .args(
        z.object({ rowIndex: z.number(), row: z.record(z.string(), z.any()) })
      )
      .returns(z.record(z.string(), z.any())),
    z.record(z.string(), z.any()),
  ])
)

export const transformConfigSchema = z.intersection(
  transformConfigOptionsSchema,
  z.record(
    z
      .string()
      .refine((s) => s !== '$mode' && s !== '$parseJson')
      .describe('schema'),
    transformConfigTableSchema
  )
)
export type TransformConfig = z.infer<typeof transformConfigSchema>

Expected to be able to parse this kind of object:

{
        $mode: 'auto',
        public: {
          books: (ctx) => ({ title: 'A Long Story' }),
        },
},

But it fails if I pass both the $mode and public: {...} parameters. However if I remove the $mode, then it's working fine. Also the type from the schema when infered seems correct:

type TransformConfig = {
    $mode?: "auto" | "strict" | "unsafe" | undefined;
    $parseJson?: boolean | undefined;
} & Record<string, Record<string, Record<string, any> | ((args_0: {
    rowIndex: number;
    row: Record<string, any>;
}, ...args_1: unknown[]) => Record<...>)>>

Edit:

I managed a workaround by using a custom validator:

const optionsKeys = ['$mode', '$parseJson']
const customTransformConfigValidator = (transformConfig: unknown) => {
  if (typeof transformConfig !== 'object' || !transformConfig) {
    throw new ZodError([
      {
        code: 'invalid_type',
        expected: 'object',
        received: transformConfig as any,
        path: ['transform'],
        fatal: true,
        message: 'Transform is not a valid objet',
      },
    ])
  }

  for (const [key, value] of Object.entries(
    transformConfig as Record<string, unknown>
  )) {
    // Validate special keys
    if (optionKeys.includes(key)) {
      transformConfigOptionsSchema.parse({ [key]: value })
    } else {
      transformConfigTableSchema.parse({ [key]: value })
    }
  }
  return true
}

const customTransformConfigSchema = z.custom<
  z.infer<
    typeof transformConfigOptionsSchema | typeof transformConfigTableSchema
  >
>(customTransformConfigValidator)

export type TransformConfig = z.infer<typeof customTransformConfigSchema>
itschrismck commented 1 year ago

Facing the same problem, the inferred type is correct but fails when parsing. For example:

const CommonKeysSchema = z.object({
  order: z.string().optional(),
  sortBy: z.string().optional(),
})

const MappingFileSchema = z.intersection(
  CommonKeysSchema,
  z.record(z.nativeEnum(ENUM), AnotherSchema)
)
stale[bot] commented 1 year ago

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.

mnn commented 1 year ago

It is still a problem in 3.22.2.

import {z} from 'zod';

type SetType = 'a' | 'b' | 'c';
type OrigType = { [key in SetType]?: number[] } & { disabled?: boolean };

const SetTypeSchema = z.enum(['a', 'b', 'c']);
const TypeSchema = z.intersection(
  z.record(SetTypeSchema, z.array(z.number())),
  z.object({
    disabled: z.boolean().optional()
  })
);

type Type = z.infer<typeof TypeSchema>;

const a: OrigType = {a: [1]};
const zA: Type = a; // ok
TypeSchema.parse(a); // ok
console.log('a OK');

const b: OrigType = {b: [1], disabled: true};
const zB: Type = b; // ok
TypeSchema.parse(b); // runtime error: `Invalid enum value. Expected 'a' | 'b' | 'c', received 'disabled'` and `Expected array, received boolean`
console.log('b OK');

Type in my case looks correct, but I am not sure if z.record should really be making all fields optional by default (Partial). Copied from IDE:

type Type = Partial<Record<"a" | "b" | "c", number[]>> & {     disabled?: boolean | undefined; }
CapitaineToinon commented 8 months ago

I'm having a similar issue with 3.22, basically I have an object with a uids key with is an array of string containing the ids of the entities which also are keys in the same object (yeah I agree that kinda sucks but that's the shape of an api I don't control). Basically, my desired type is:

type MyObject = { uids: string[] } & Record<string, { uid: string }>

I can create this schema:

const result = z.intersection(
  z.record(
    z.object({
      uid: z.string(),
    })
  ),
  z.object({
    uids: z.array(z.string()),
  })
);

Which z.infer tells me is:

type Result = Record<string, {
    uid: string;
}> & {
    uids: string[];
}

And the result is inferred correctly in vscode:

image

But when you run the code, the parsing fails because it tries to parse both types for all keys:

ZodError: [
  {
    "code": "invalid_type",
    "expected": "object",
    "received": "array",
    "path": [
      "result",
      "uids"
    ],
    "message": "Expected object, received array"
  }
]

As of right now I haven't found a workaround.

RobJJ commented 4 months ago

Ok, so not sure if this helps anyone but I have been painfully trying to deal with this type of error myself

"message": "Expected object, received array"

which stems from using the z.record() approach above on my schema to help include some dynamic keys.

My example comes from using React-Hook-Form and trying to connect my dynamic input to the dynamic schema key. For some reason, if I give it a small tag before the string() key it can correctly infer the type and everything pulls in correctly.

eg. When registering my RHF input field that is deeply nested

const deeplyNestedSchema = z.object({ first: z.string(), year_amounts: z.record(z.string(), z.coerce.number()) })

{...register(first.second.${index}.year_amounts.${dynamicKey}} this doesnt work...

{...register(first.second.${index}.yearamounts.**tag${dynamicKey}} this does** work...

Not fully sure why this works and the other approach causes so much pain but its helped move my project along. For context, had tried intersection / .and / .transform etc