colinhacks / zod

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

Strict by default or strict deep #2062

Open hornta opened 1 year ago

hornta commented 1 year ago

Hi, I would like to either have strict on all of my types or something like .deepStrict() that propagates down on all sub objects in my schema. Now it's too easy to miss out on defining a schema as strict I think.

gawi commented 1 year ago

I also think it would be worthwhile to have this in the API (and possibly deepPassthrough() and deepStrip()).

If you're in a hurry, I think you can implement it yourself. Take a look at deepPartial+deepPartialify for inspiration.

  deepPartial(): partialUtil.DeepPartial<this> {
    return deepPartialify(this) as any;
  }
function deepPartialify(schema: ZodTypeAny): any {
  if (schema instanceof ZodObject) {
    const newShape: any = {};

    for (const key in schema.shape) {
      const fieldSchema = schema.shape[key];
      newShape[key] = ZodOptional.create(deepPartialify(fieldSchema));
    }
    return new ZodObject({
      ...schema._def,
      shape: () => newShape,
    }) as any;
  } else if (schema instanceof ZodArray) {
    return ZodArray.create(deepPartialify(schema.element));
  } else if (schema instanceof ZodOptional) {
    return ZodOptional.create(deepPartialify(schema.unwrap()));
  } else if (schema instanceof ZodNullable) {
    return ZodNullable.create(deepPartialify(schema.unwrap()));
  } else if (schema instanceof ZodTuple) {
    return ZodTuple.create(
      schema.items.map((item: any) => deepPartialify(item))
    );
  } else {
    return schema;
  }
}
gawi commented 1 year ago

I've tried to generalize to all kind of deep unknown policies. Here's what I got:

First, you need 2 utilities: a function and a type.

type ZodObjectMapper<T extends ZodRawShape, U extends UnknownKeysParam> = (
  o: ZodObject<T>
) => ZodObject<T, U>;

function deepApplyObject(
  schema: ZodTypeAny,
  map: ZodObjectMapper<any, any>
): any {
  if (schema instanceof ZodObject) {
    const newShape: Record<string, ZodTypeAny> = {};
    for (const key in schema.shape) {
      const fieldSchema = schema.shape[key];
      newShape[key] = deepApplyObject(fieldSchema, map);
    }
    const newObject = new ZodObject({
      ...schema._def,
      shape: () => newShape,
    });
    return map(newObject);
  } else if (schema instanceof ZodArray) {
    return ZodArray.create(deepApplyObject(schema.element, map));
  } else if (schema instanceof ZodOptional) {
    return ZodOptional.create(deepApplyObject(schema.unwrap(), map));
  } else if (schema instanceof ZodNullable) {
    return ZodNullable.create(deepApplyObject(schema.unwrap(), map));
  } else if (schema instanceof ZodTuple) {
    return ZodTuple.create(
      schema.items.map((item: any) => deepApplyObject(item, map))
    );
  } else {
    return schema;
  }
}
type DeepUnknownKeys<
  T extends ZodTypeAny,
  UnknownKeys extends UnknownKeysParam
> = T extends ZodObject<infer Shape, infer _, infer Catchall>
  ? ZodObject<
      {
        [k in keyof Shape]: DeepUnknownKeys<Shape[k], UnknownKeys>;
      },
      UnknownKeys,
      Catchall
    >
  : T extends ZodArray<infer Type, infer Card>
  ? ZodArray<DeepUnknownKeys<Type, UnknownKeys>, Card>
  : T extends ZodOptional<infer Type>
  ? ZodOptional<DeepUnknownKeys<Type, UnknownKeys>>
  : T extends ZodNullable<infer Type>
  ? ZodNullable<DeepUnknownKeys<Type, UnknownKeys>>
  : T extends ZodTuple<infer Items>
  ? {
      [k in keyof Items]: Items[k] extends ZodTypeAny
        ? DeepUnknownKeys<Items[k], UnknownKeys>
        : never;
    } extends infer PI
    ? PI extends ZodTupleItems
      ? ZodTuple<PI>
      : never
    : never
  : T;

Then we only need to define those 3 functions + types:

type DeepPassthrough<T extends ZodTypeAny> = DeepUnknownKeys<T, 'passthrough'>;
function deepPassthrough<T extends ZodTypeAny>(schema: T): DeepPassthrough<T> {
  return deepApplyObject(schema, (s) => s.passthrough()) as DeepPassthrough<T>;
}

type DeepStrip<T extends ZodTypeAny> = DeepUnknownKeys<T, 'strip'>;
function deepStrip<T extends ZodTypeAny>(schema: T): DeepStrip<T> {
  return deepApplyObject(schema, (s) => s.strip()) as DeepStrip<T>;
}

type DeepStrict<T extends ZodTypeAny> = DeepUnknownKeys<T, 'strict'>;
function deepStrict<T extends ZodTypeAny>(
  schema: T,
  error?: errorUtil.ErrMessage
): DeepStrict<T> {
  return deepApplyObject(schema, (s) => s.strict(error)) as DeepStrict<T>;
}

Note that I've left some any in the definitions above. Open to any suggestion to fix this.

And that's it. Sample usage:

const schema = z.object({
  a: z.string(),
  b: z.object({
    a: z.string(),
    b: z.array(
      z.object({
        a: z.string(),
      })
    ),
  }),
});

const value = {
  a: 'value',
  b: {
    a: 'value',
    b: [{ a: 'value' }, { a: 'value', c: 'unknown' }],
    c: 'unknown',
  },
  c: 'unknown',
};

const stripSchema = deepStrip(schema);
console.log(JSON.stringify(stripSchema.parse(value)));
// => {"a":"value","b":{"a":"value","b":[{"a":"value"},{"a":"value"}]}}

const passthroughSchema = deepPassthrough(schema);
console.log(JSON.stringify(passthroughSchema.parse(value)));
// => {"a":"value","b":{"a":"value","b":[{"a":"value"},{"a":"value","c":"unknown"}],"c":"unknown"},"c":"unknown"}

const strictSchema = deepStrict(schema, 'ERROR');
console.log(JSON.stringify(strictSchema.parse(value)));
// => throws
slapierre commented 1 year ago

Caveat: the implementation provided by @gawi suffers the same shortcomings as deepPartial:

Important limitation: deep partials only work as expected in hierarchies of objects, arrays, and tuples.

The policy of a recursive type (defined with z.lazy) cannot be changed with the current implemetation of deepApplyObject, it would need to be enhanced to handle a schema of type ZodType<T, ZodTypeDef, U>, it doesn't look like a trivial change.

colinhacks commented 1 year ago

deepPartial is very fragile and probably shouldn't have been added.

Zod intentionally avoids "modes" and contextual validation - it's too hard to reason about. Using z.strictObject everywhere is the recommended approach. You might be able to make a custom lint rule to prevent any usage of z.object. If you do that, ping me and I'll add it to the readme so others can use it too.

hyperscientist commented 1 year ago

For consuming APIs I would almost always pick z.object, but for data exploration as I am doing now z.strictObject is necessary and I am glad I found about it. Of course if you want highly focused lib (on consuing known APIs) with great DX than it may not make sense to add it. Just sharing my pov.

alokmenghrajani commented 4 months ago

TypeScript's structural-based type system makes it easy to accidentally leak data (e.g. include password hashes in an HTTP response after fetching a User row from a database table) when building web applications. Thanks to this issue, I know about z.object().strict()/z.strictObject() and strict parsing is a great way to work around some of these risks: I can parse responses prior to returning them to the user and fail if there are extra fields.

Defining all the objects to be .strictObject is not ideal in some circumstances. It can make changes hard. For instance, a web client + web server pair need to be deployed at the exact same time if they are sharing their zod schema. Alternatively, the schema's new fields need to first be made optional, requiring multiple deploys to reach the end state.

I'm therefore writing this comment to +1 the idea of adding .deepStrict() or having a strictParse() which behaves like parse() but considers object to be strictObject. Backend could expose non-strict schemas but use strict schemas to validate data prior to sending responses. In a similar way, frontends could implement strict schema parsing prior to making a request but the server can accept additional fields.

P.S. I'm a long time zod fan, first time sharing some feedback -- thanks a lot of writing and maintaining this high quality library over the years!