colinhacks / zod

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

Transform strings to normalize URLs #3323

Open chriskuech opened 5 months ago

chriskuech commented 5 months ago

Problem

Users typically do not include the protocol when hand-typing links. As such, applying z.string().url() leads to unintuitive use experience when validating user input. Further a custom transformation (ex: z.string().transform(normalizeUrlLikeString).url()) is not supported by Zod.

Proposed solution

A .toUrl() transformation function that will--

  1. Add https:// if the string does not include a protocol
  2. Validate the string as a URL
  3. Return the new/valid string.
JacobWeisenburger commented 5 months ago

Is this what you are looking for?

const schema = z.union( [
    z.string().url(),
    z.string().transform( x => x.startsWith( 'https://' ) ? x : `https://${ x }` )
] )

console.log( schema.parse( 'foo.com/bar/baz' ) ) // https://foo.com/bar/baz

If you found my answer satisfactory, please consider supporting me. Even a small amount is greatly appreciated. Thanks friend! 🙏 https://github.com/sponsors/JacobWeisenburger

colinhacks commented 5 months ago

I understand the desire for something like this and your use case makes sense. For now I recommend using pipe.

z.string().transform(normalizeUrlLikeString).pipe(z.string().url())

Hopefully it's clear why you can't call .url() after .transform(), since your transform could return a value of any data type, not just strings.

Zod could to support some way to mutate input in a non-type-transforming way. This would differ from transform, which lets you transform the input at will and return a value of any type. The .mutate method would return an instance of the same class, instead of a ZodTransform.

z.string().mutate(val => {
   return val.startsWith("http") ? val : `http://${val}`
})
// => returns a ZodString instance

Mutations would differ from transforms in that they can't modify the inferred type. Zod would enforce this statically. The only argument against is that I think this distinction would be lost on most people and there's potential for confusion.

It would perhaps be less confusing to somehow support this inside superRefine. You could return the new value from .superRefine, for instance. Or .superRefine could provide a method like resolve on the ctx object (similar to the notion of Promise's resolve.)

z.string().superRefine((val, ctx) => {
   ctx.val;  // access input data
   ctx.addIssue(..);   // add issues (similar to super refine)
   ctx.resolve(val.startsWith("http") ? val : `http://${val}`);
});

Still weighing these options. Leaving open for discussion.

Svish commented 4 months ago

Maybe this could just be solved with an option passed to the .url()? Like z.string().url({ protocolOption: true })? Best is probably just to handle it via pipe though, as I think the way this is handled is very custom depending on the case.