fabian-hiller / valibot

The modular and type safe schema library for validating structural data 🤖
https://valibot.dev
MIT License
5.88k stars 181 forks source link

Validation for multiple possible values or notValues #716

Open is-jonreeves opened 1 month ago

is-jonreeves commented 1 month ago

I was looking through the validators to see if there was an easy way to blacklist or whitelist a set of strings/numbers (all in one go). I see that .value and .notValue are there, but didn't see any ones that would allow for an array of items to check against.

I'm aware this could be done with a custom .check or .regex, but wondered if it was a common enough usecase where we might consider adding .values and .notValues?

is-jonreeves commented 1 month ago

On a related note, NaN doesn't play well with .value or .notValue...

This is more of a JS issue though, as NaN === NaN is always false. Dunno if its a scenario worth handling, but had me scratching my head for a moment. I'm just using v.check(i => !Number.isNaN(i), () => I18n.t('validation.notNumber')) instead.

fabian-hiller commented 1 month ago

Thanks for sharing that idea. Yes, we can add values and notValues. Feel free to create a PR. Instead of using values you may want to consider using picklist or literal with union first as it leads to a more precise TypeScript type.

Can you share your entire !NaN schema? v.number() already excludes NaN for you.

is-jonreeves commented 1 month ago

Can you share your entire !NaN schema? v.number() already excludes NaN for you.

This likely occurred for me because I'm also allowing string as a valid input and coercing to number. The intention here was to first conform every valid input to the expected type allowing them to share the same validation logic:

v.pipe(
  // Input
  v.union([
    // Expected (intended "input" type)
    v.number(),
    // Coerced (supported alternative "input" types)
    v.pipe(v.string(), v.trim(), v.transform(i => Number(i || NaN))),
  ]),
  // Validation
  // v.notValue(NaN, () => 'Not a Number'), // <--- couldn't use this
  v.check(i => !Number.isNaN(i), () => 'Not a Number'),
  v.integer(() => 'Not an Integer'),
  v.minValue(1, () => 'Too Small'),
  v.maxValue(65535, () => 'Too Large'),
);

The tricky thing with Number(string) is that an empty string evaluates to 0, so it makes more sense to force blank to NaN. I could instead force to null, but the number validations would then misbehave.

The bigger picture is that I was trying to recreate a Zod usage we had, where we needed "optional numbers", and I recalled it being a bit of a pain... Essentially, a form input (string) where the value should be coerced to a number, is within a range, but can also be optional (with no fallback). Here is an example:

import * as v from 'valibot';

/** Schemas */
const AsNumberSchema = v.union([
  // Expected (intended "input" type)
  v.number(),
  // Coerced (supported alternative "input" types)
  v.pipe(v.string(), v.trim(), v.transform(i => Number(i || NaN))),
]);

const AsUndefinedSchema = v.union([
  // Optional (coercing null as undefined)
  v.pipe(v.null(), v.transform(() => undefined)),
  v.undefined(),
]);

const PortSchema = v.pipe(
  // Supported Types
  AsNumberSchema,
  // Validation
  v.check(i => !Number.isNaN(i), () => 'Not a Number'),
  v.integer(() => 'Not an Integer'),
  v.minValue(1, () => 'Too Small'),
  v.maxValue(65535, () => 'Too Large'),
);

const ConfigurationSchema = v.object({
  port: v.union([ PortSchema, AsUndefinedSchema ]),
});

/** Types */
type ConfigurationSchema = v.InferOutput<typeof ConfigurationSchema>;

/** Debug */
const result = v.safeParse(ConfigurationSchema, { port: undefined }, { abortPipeEarly: true });
console.log(result);

There might be a better (more repeatable) approach to this, as I would also need to do similar string/undefined support for booleans (who don't have an "invalid" state to work with). So maybe using null could make more sense, but I'd need to find a way to restructure it to bypass these validations.

Alternatively, perhaps the better option is to just create a separate dedicated Form/UserInput version of the schemas (that always assumes a string as input) rather than one that caters for everything. I was just trying to avoid repetition, but potentially separating out these concerns makes more sense.

Instead of using values you may want to consider using picklist or literal with union first as it leads to a more precise TypeScript type.

With the above in mind, the scenario I was curious about, was if I needed to exclude a set of reserved ports from those available. If it was always a non-contiguous list then something like .notValues([], ...) would have been handy, but in this case its more likey to be a collection of ranges, so a custom .check probably makes more sense anyways. It just occurred to me that a whitelist/blacklist validation could be handy in other situations.

fabian-hiller commented 1 month ago

Yes, let's keep this open. Maybe someone in the community will implement it, or I will do it at a later date.