jquense / yup

Dead simple Object schema validation
MIT License
22.93k stars 934 forks source link

Cleanest way to achieve something like `map` where given a parser to type `T` and function `A->B` you get a `Schema<B>`? #2186

Open jcoveney-anchorzero opened 8 months ago

jcoveney-anchorzero commented 8 months ago

So basically, imagine a simple case where I have a function stringToThing from string->Thing. What I want to do is something like...

type a = {
  field: Yup.string().required().transform((v) => stringtoThing(v));
}

right now, the type of field would still be a string. usually if I want a parser to a custom type I need to use a Yup.mixed, but I'm not sure. I could certainly write one, but that feels like it would be hacky. something else that I could do would be something like...

type a = {
  field: castSchemaToType<Thing>(Yup.string().required().transform((v) => stringtoThing(v)));
}

and have a function that takes a Schema<string, B, C, D> and coerces it to a Schema<Thing, B, C, D>. But I'm not sure if that could have unexpected side-effects, since the underlying class at run time in this case for example would be a StringSchema (I believe)

any thoughts on the best way to achieve this? this sort of composition would be quite useful

jquense commented 8 months ago

Schema describe the final type not the input. If you have, write a schema describing Thing and use a transform to turn the input into it

jcoveney-anchorzero commented 8 months ago

well the thing is that sometimes it'd be nice to leverage a schema you already have...if an arbitrary schema<A> can be thought of as a function from unknown -> A | null, then if you need a schema<B> and have A -> B it'd be nice to leverage schema<A>, even if just as a function that "gets you almost there." essentially...

function yupMap<A, B extends {}>(schema: Yup.Schema<A>, fn: (a: A) => B): Yup.MixedSchema<B> {
  // might throw an exception
  const fn2 = (value: A) => fn(schema.validateSync(value));
  return Yup.mixed<B>((value): value is B => {
    try {
      fn2(value);
      return true;
    } catch (_) {
      return false;
    }
  })
    .transform((value: any, _input, ctx) => (!value || ctx.isType(value) ? value : fn2(value)))
    .required();
}

this is obviously a slightly crude attempt, but gets the point across. there are a number of reasons this isn't ideal, though you'd probably know even more! the main one I can think of is that invoking schema.validateSync() in this way doesn't pick up the configurations to your top level invocation. so like if this yupMap were used, then you use .validate(, {some args}), those args would not be used in that call to schema.validateSync()

still, I think it's pretty natural to want to be able to re-use the pieces in yup, or that we've already built...curious if you have thoughts on best practices for that. at this point we have a ton of yup validators which is why this sort of thing is becoming more and more desirable for us (a testament to yup's usefulness, for which we are very thankful!)