colinhacks / zod

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

[Feature Request] - Property mapping for smoother object chain construction - mapProps(), and mapPropsPicked() #2644

Open akutruff opened 1 year ago

akutruff commented 1 year ago

For input validation, Zod sometimes feels like it's copy/paste error prone and feels "reversed." Often, you validate inputs that are going to be sent to another model type that will be written to an API or database. The types of the properties of the inputs and the destination type usually have the same data type, or only a few properties on the input need to be coerced, refined, or transformed before simply being copied to another type. I would also like the some of the errors on the model type to be surfaced immediately before any other logic takes place.

Also, autocomplete is greatly hindered as making sure the property names and property types stay in sync is difficult.

I based a lib I wrote almost entirely off Zod, and added this functionality and it's been a big help. You can see the documentation which mirrors Zod's docs, here:

https://github.com/kutruff/nimbit#mapprops

I'd love for this functionality to also be in Zod.

const Address = z.object({
  street: z.string(),
  zipcode: z.string().length(5) // validate zip is 5 chars.
});

const Person = z.object({
  name: z.string(),
  age: z.number().min(0), //An existing validation on the model
  address: Address,
  title: z.string()
});

const PersonInput = z.object({
  //z.string() is copy pasted here rather than just using the model.
  name: z.string().default(''),
  //Oops. The age validation on the model isn't caught early...
  age: z.string().pipe(z.coerce.number()),
  //Need to validate sub props and coerce Address...
  //Can't reuse the Address type and we didn't get the validation. Using merge() won't guarantee prop names match.
  address: z.object({ 
    street: z.string().refine(x => x.includes('St.')),
    //expect zipcode to be a number for input but we just forgot the Address validation.
    zipcode: z.number().pipe(z.coerce.string()) 
  })
});

Now imagine if we had mapProps() that allowed us on a property by property basis to work with the first type:

const PersonInput = Person.mapProps({
  // p is the Person.name.  It's strongly typed.
  name: p => p.default(''),
  // now we get the age validator )
  age: p => z.coerce.number().pipe(p),
  // Now we can use map props on nested objects too!
  address: p => p.mapProps({ 
    street: sub => sub.refine(x => x.includes('St.')),
    // adding pipe to reuse the length validation on Address.zipcode
    zipcode: sub => z.number().pipe(z.coerce.string()).pipe(sub) 
  })
});

mapPropsPicked() would do the same but restrict the properties would only allow the properties that were specified in the mapping.

colinhacks commented 1 year ago

This is great

Definitely on the list

Ustice commented 1 year ago

If the property mappers from z.mapProps had the signature that closely matched the Array.map function, we'd have a lot more power. Something like the following.

type PropertyMapper = <
  Type extends object, 
  Key extends keyof Type
>(
  value?: Type[Key], 
  key?: Key,
  parent: Type
): any
Person.mapProps({
  name: (value) => value.default(''),
  title: (value) => value.default(''),
  fullSigniture: (, , { name, title }) => [ name, title ].filter(Boolean).join('; ')
})

mapProps could also receive a PropertyMapper function directly, where it would iterate over the keys, and call the PropertyMapper function for each, resulting in an array. I expect this to be more used on z.record() types.

akutruff commented 1 year ago

Also, while I'm at it, can I add optional VSCode dev container support to the project? It really makes contributing to projects so much easier as it isolates your environment. It would be a separate PR.

https://code.visualstudio.com/docs/devcontainers/containers

akutruff commented 1 year ago

(Removed previous comment. ) I'm actually now not following what fullSignature would do in this case. I thought I did, but what does the array mean here? Are the decomposed parameters the value to be parsed and not the types? Initially I thought name and title were the types.

Are you mixing transforms with type pipelining?

akutruff commented 1 year ago

@Ustice At present, the design of mapProps function doesn't extend the source object. It restricts it to just existing properties so that you are guaranteed that your property names do not deviate as you make changes.