Skip to content

Instantly share code, notes, and snippets.

@cb109
Last active June 5, 2023 11:30
Show Gist options
  • Save cb109/8eda798a4179dc21e46922a5fbb98be6 to your computer and use it in GitHub Desktop.
Save cb109/8eda798a4179dc21e46922a5fbb98be6 to your computer and use it in GitHub Desktop.
YUP: Validate that items in an Array match one of multiple allowed Schemas
/**
* The yup library has no builtin way to check if items in an array
* match one or more defined shapes, we can only check against a set of
* whitelisted values using yup.array().oneOf([..]).
*
* Using yup.addMethod() and yup.mixed().test() however we can pretty
* much add any custom validation we want. The function below allows to
* make validation pass for array items of different shapes.
*/
const assert = require('assert');
const yup = require('yup');
// Please note this method is sync. An async version would return a Promise.
yup.addMethod(yup.array, 'oneOfSchemas', function(schemas) {
return this.test(
'one-of-schemas',
'Not all items in ${path} match one of the allowed schemas',
items => items.every(item => {
return schemas.some(schema => schema.isValidSync(item, {strict: true}))
})
)
})
// Usage example below. Run like: $ nodejs yup_array_oneOfSchemas.js
const Beer = yup.object().noUnknown().shape({
'alcoholConcentration': yup.number(),
})
const Water = yup.object().noUnknown().shape({
'sparkling': yup.boolean(),
})
const Crate = yup.object().noUnknown().shape({
// Any item in the array must match either the Beer or Water schema.
'bottles': yup.array().oneOfSchemas([Beer, Water]),
})
const crate = {
bottles: [
{'sparkling': true},
{'alcoholConcentration': 5},
]
}
assert.strictEqual(Crate.isValidSync(crate, {strict: true}), true)
@DesignByOnyx
Copy link

DesignByOnyx commented Aug 2, 2019

Thanks so much - this was super helpful.

And for anybody wanting better integration of this with typescript, here you go:

/**
 * This file contains yup extensions and defaults. This file should be
 * the one imported in order to use these extensions.
 */
import * as yup from 'yup';

yup.addMethod(yup.array, 'oneOfSchemas', function oneOfSchemas(
	schemas: yup.MixedSchema[],
	message?: yup.TestOptionsMessage,
) {
	return this.test(
		'one-of-schemas',
		message || 'Not all items in ${path} match one of the allowed schemas',
		(items: any[]) =>
			items.every(item => {
				return schemas.some(schema =>
					schema.isValidSync(item, { strict: true }),
				);
			}),
	);
});

declare module 'yup' {
	// tslint:disable-next-line: interface-name
	export interface ArraySchema<T> {
		/**
		 * Allows you to define mutliple disparate types which should
		 * be considered valid within a single array. You can tell the method
		 * what types of schemas to expect by passing the schema types:
		 *
		 * ```
		 * // Array of object schemas
		 * yup.array().oneOfSchemas<yup.ObjectSchema>([
		 *     yup.object().shape({ ... })
		 * ]);
		 *
		 * // Array of object or string schemas
		 * yup.array().oneOfSchemas<yup.ObjectSchema | yup.StringSchema>([
		 *     yup.object().shape({ ... }),
		 *     yup.string()
		 * ]);
		 * ```
		 *
		 * @param schemas A list of yup schema definitions
		 * @param message The message to display when a schema is invalid
		 */
		oneOfSchemas<U>(schemas: U[], message?: TestOptionsMessage): this;
	}
}

export * from 'yup';

@dwjohnston
Copy link

Does this gist relate to an issue?

@cb109
Copy link
Author

cb109 commented Mar 18, 2020

@dwjohnston I don't remember to be honest. I was testing the whole thing but later ditched it since it had some performance issues with larger/complex JSON objects.

@seansullivan
Copy link

Thanks for this. Very helpful!

@dwjohnston
Copy link

I will just point out for others who might have stumbled on this - that this solution really does have performance problems if you have a deep or large validation tree.

@DesignByOnyx
Copy link

DesignByOnyx commented Jul 27, 2020

In practice I found this validator to be too vague and unhelpful as it usually just errors with "the data doesn't match" without providing any helpful information - especially for end users. It's also a performance hog. It's generally better to write a validation function which knows what type to look for (eg. using duck typing or checking values of other properties). Unfortunately, that's something than cannot be written generically. I don't have an example from where I did this, but if I remember correctly, we either used the when() and/or test() utilities depending on the situation. The test utility is nice because you can have dynamic error messages:

yup.object().shape({
   ...
   foo: yup.array().test('foo', '', function(value) { // note: don't use an arrow function
      if(valueIsNotFoo) {
         return this.createError(...); // return a custom error (see docs)
      }
      return true; // everything is valid
   })
   ...
})

@DesignByOnyx
Copy link

DesignByOnyx commented Jul 27, 2020

@david-wb - Did you add the typescript definitions as described above? The above code works, so something else must be wrong and this is not the place to debug it. Please ask questions like this on StackOverflow, and please include code snippets so people can help.

@DesignByOnyx
Copy link

As stated in my last comment, please ask this question on StackOverflow. This is not the place to debug issues. Since you made me type this again, just do (this as any).test(...). Please do not continue this discussion here.

@ddoice
Copy link

ddoice commented Sep 29, 2021

Your mileage may vary, validating not very complex objects in node with this solution we have a throughput of 17K validations (sync) per second on a 3700X.

But we ended discarding this method because we cannot infer which is the right one in case of error to throw a meaningful error with createError.

const subschema1 = yup.object().noUnknown().shape({
  type: yup.string().oneOf(['type1']).required(),
  code: yup.string().min(6).required(),
  number: yup.string().min(5).required(),
  date: yup.string().required(),
  amount: yup.number().required().positive().integer(),
  field1: yup.string().url().required(),
  field2: yup.object({
    field3: yup.string().required(),
  }),
});

const subschema2 = yup.object().noUnknown().shape({
  type: yup.string().oneOf(['type2']).required(),
  code: yup.string().min(6).required(),
  number: yup.string().min(5).required(),
  dateDelivery: yup.string().required(),
  amount: yup.number().required().positive().integer(),
  field1: yup.string().url().required(),
  field4: yup.object({
    field5: yup.string().required(),
  }),
});

const schema = yup.object().noUnknown().shape({
  name: yup.string().required(),
  code: yup.string().required(),
  services: yup.array().min(1).oneOfSchemas([subschema1, subschema2]),
});

@ddoice
Copy link

ddoice commented Sep 29, 2021

This is how we finally did it, we created a new method called 'requiredByAssertion', which allows us to require some fields based on another field value assertion.

Following the last example:

yup.addMethod(yup.mixed, 'requiredByAssertion', function ([fields, assertion]) {
  return this.when(fields, {
    is: (...args) => assertion(...args),
    then: (obj) => obj.required(),
  });
});

const isType1 = [['type'], (type) => type === 'type1'];
const isType2 = [['type'], (type) => type === 'type2'];

const serviceSchema = yup.object().shape({
  type: yup.string().oneOf(['type1','type2']).required(),
  code: yup.string().min(6).required(),
  number: yup.string().min(5).required(),
  amount: yup.number().required().positive().integer(),
  field1: yup.string().url().required(),
  date: yup.string().requiredByAssertion(isType1),
  dateDelivery: yup.string().requiredByAssertion(isType2),
  field2: yup.object({
    field3: yup.string().required(),
  }).default(null).nullable().requiredByAssertion(isType1),
  field4: yup.object({
    field5: yup.string().required(),
  }).default(null).nullable().requiredByAssertion(isType2),
});

const schema = yup.object().noUnknown().shape({
  name: yup.string().required(),
  code: yup.string().required(),
  services: yup.array().min(1).of(serviceSchema),
});

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment