anchorQuick look at Yup
Yup provides factory methods for different data types that you can use to construct schemas and their constraints for your data. The following code imports the string
schema, and calls the required
and email
methods to describe what type of data is allowed. The returned schema emailRequired
validates values to be "a valid non-empty string that matches an email pattern".
import { string } from "yup";
const emailRequired = string().required().email();
emailRequired.isValid("wrong.email.com").then(function (valid) {
valid; // => false
});
anchorDescribing objects with Yup
Let's see how we can create a schema that describes some complex data. Here's some example found in the Yup docs:
import { object, string, number, date } from "yup";
const schema = object().shape({
name: string().required(),
age: number().required().positive().integer(),
email: string().email(),
website: string().url(),
createdOn: date().default(function () {
return new Date();
}),
});
object().shape()
takes an object as an argument whose key values are other Yup schemas, then it returns a schema that we can cast or validate against i.e
schema
.isValid({
name: "jimmy",
age: 24,
})
.then(function (valid) {
valid; // => true
});
anchorPutting it together
import { tracked } from "@glimmer/tracking";
import { getProperties } from "@ember/object";
import { object } from "yup";
export default class YupValidations {
context;
schema;
shape;
@tracked error;
constructor(context, shape) {
this.context = context;
this.shape = shape;
this.schema = object().shape(shape);
}
get fieldErrors() {
return this.error?.errors.reduce((acc, validationError) => {
const key = validationError.path;
if (!acc[key]) {
acc[key] = [validationError];
} else {
acc[key].push(validationError);
}
return acc;
}, {});
}
async validate() {
try {
await this.schema.validate(this.#validationProperties(), {
abortEarly: false,
});
this.error = null;
return true;
} catch (error) {
this.error = error;
return false;
}
}
#validationProperties() {
return getProperties(this.context, ...Object.keys(this.shape));
}
}
That's it :) Let's go through it real quick.
There are 4 properties defined context
, schema
, shape
and error
which is @tracked
.
context
is either the data or the whole instance of the object we're validating.shape
the expected shape of the data; this is used to createschema
schema
is a Yup schema created fromshape
error
is aValidationError
thrown byschema.validate()
when the data doesn't match theschema
.
The constructor takes 2 arguments context
and shape
, we set those on the instance as well as create schema
off of shape
.
fieldErrors
is a computed property that returns an object which keys are paths to the schemas that failed validations. The object is created by reducing a list of errors read fromValidationError
.validate
is an asynchronous method that callsvalidate
on ourschema
, awaits the result, resets errors if validations pass, otherwise catches an error and sets it on the class instance. It's important that theabortEarly: false
option is passed toschema.validate()
as otherwise if any field would throw an error, it would stop validating the rest of the data, which is not desired. Furthermorevalidate
receives data returned from#validationProperties
, the reason for that being Ember Proxies. In order to correctly support proxies, e.g Ember-Data models, we need to grab data fromcontext
with the help ofgetProperties
.
anchorUsage
import Model, { attr, hasMany } from "@ember-data/model";
import YupValidations from "emberfest-validations/validations/yup";
import { number, string } from "yup";
export default class UserModel extends Model {
validations = new YupValidations(this, {
name: string().required(),
age: number().required(),
});
@attr("string") name;
@attr("number") age;
@hasMany("pet") pets;
}
We instantiate YupValidations and pass it some arguments, a User model instance, and Yup shape.
Later, inside a form component I'd like to be able to just call YupValidations#validate
method which returns a Promise that resolves to a Boolean.
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
export default class UserFormComponent extends Component {
@service store;
user = this.store.createRecord("user");
@action
async onSubmit(event) {
event.preventDefault();
if (await this.user.validations.validate()) {
this.user.save();
}
}
}
Further on, there's an ErrorMessage
component that receives a list of error messages and handles them in some way. Normally we'd show internationalized messages with the help of ember-intl
, but in our case we'll just show their keys directly like so:
{{! error-message.hbs }}
{{#each @messages as |message|}}
<div>
{{message.key}}
</div>
{{/each}}
Finally the component invocation:
<ErrorMessage @messages={{this.user.validations.fieldErrors.age}} />
anchorInternationalization
Yup by default returns messages in plain text based on some built-in templates and some way of concatenation. That's not an option for us though because in Ember apps we normally use ember-intl
for translating text. Luckily Yup allows to use functions that will produce messages.
The full list of messages can be found in Yup's source code
import { setLocale } from ‘yup’;
import { getProperties } from ‘@ember/object’;
const locale =
(key, localeValues = []) =>
(validationParams) => ({
key,
path: validationParams.path,
values: getProperties(validationParams, ...localeValues),
});
setLocale({
mixed: {
default: locale('field.invalid'),
required: locale('field.required'),
oneOf: locale('field.oneOf', ['values']),
notOneOf: locale('field.notOneOf', ['values']),
defined: locale('field.defined'),
},
string: {
min: locale('string.min', ['min'])
},
})
locale
is a higher order function that returns a function that is later used by Yup to produce our messages. The first argument it takes is a translation key of our liking that would then be consumed by ember-intl
e.g field.invalid
. The second argument is a list of fields it should get from validationParams
that we receive from Yup, the parameters could have values like min
and max
that would be passed to ember-intl
t
helper.
At the end of the day the produced messages will look like this:
{
key: "string.min",
path: "name",
values: { min: 8 }
}
{
key: "field.required",
path: "age",
values: {}
}
Let's see what messages are returned after the User
model is validated when the form is submitted:
anchorValidating related schemas
As you could see before, pets aren't being validated yet. There are 2 things that need to be done first:
- Create validations for the
Pet
model. - Validate pets when the user is validated.
Here's the Pet
model with validations.
import Model, { attr, belongsTo } from "@ember-data/model";
import { string } from "yup";
import YupValidations from "emberfest-validations/validations/yup";
export default class PetModel extends Model {
validations = new YupValidations(this, {
name: string().required(),
});
@attr("string") name;
@belongsTo("user") owner;
}
Now let's take a look at the code for connecting the validations for User
and Pet
. This will validate pets when a User
is validated.
Yup has a public API for extending schemas as well as creating custom tests, both can be used to create the connection we want.
addMethod
accepts a schema, a name of a method that is to be added on the target schema, as well as a function.mixed#test
is a method that exists on all schemas, it can be used in multiple ways, but in this case the only thing we need to know is that it's a method that receives a function as an argument, and the function it receives has to return a promise that returnstrue
orfalse
.
anchorbelongsTo
import { addMethod, object } from "yup";
addMethod(object, "relationship", function () {
return this.test(function (value) {
return value.validations.validate();
});
});
This bit is fairly straightforward, we add a relationship
method to the object
schema. When relationship
is called, it adds a custom test that receives value
which is an instance of a model. After that it's just a matter of accessing the validaitons wrapper and running it's validate
method.
anchorhasMany
import { addMethod, array } from "yup";
addMethod(array, "relationship", function () {
return this.transform(
(_value, originalValue) => originalValue?.toArray() || []
).test(async function (value) {
const validations = await Promise.allSettled(
value.map(({ validations }) => {
return validations.validate();
})
);
return validations.every(validation => validation);
});
});
hasMany
relationships are pretty much the same as belongsTo
. The only difference is that the hasMany
promise-proxy needs to be transformed into an array that Yup will be able to handle which is done by calling toArray
. Then the test validates all object in the array and check whether all validations return true
.
That's it – now we need to modify the User
model by adding pets
to its validations.
import Model, { attr, hasMany } from "@ember-data/model";
import YupValidations from "emberfest-validations/validations/yup";
import { number, array, string } from "yup";
export default class UserModel extends Model {
validations = new YupValidations(this, {
name: string().required(),
age: number().required(),
pets: array().relationship(),
});
@attr("string") name;
@attr("number") age;
@hasMany("pet") pets;
}
anchorConditional validations
There are times when you need to do some conditional validations based on some different state. Here we'll have a really weird requirement where pet.name
is only required when the user is not allergic.
import Model, { attr, hasMany } from "@ember-data/model";
import YupValidations from "emberfest-validations/validations/yup";
import { number, array, string } from "yup";
export default class UserModel extends Model {
validations = new YupValidations(this, {
name: string().required(),
age: number().required(),
pets: array().relationship(),
});
@attr("string") name;
@attr("number") age;
@attr("boolean") isAllergic;
@hasMany("pet") pets;
}
The only thing added here is a new isAllergic
attribute.
Here's the modified Pet
model:
import Model, { attr, belongsTo } from "@ember-data/model";
import { boolean, string } from "yup";
import YupValidations from "emberfest-validations/validations/yup";
export default class PetModel extends Model {
validations = new YupValidations(this, {
name: string().when(["isUserAllergic"], {
is: true,
then: string().notRequired(),
otherwise: string().required(),
}),
isUserAllergic: boolean(),
});
@attr("string") name;
@belongsTo("user") user;
get isUserAllergic() {
return this.user.get("isAllergic");
}
}
Pet
now implements the conditional name
validation by calling the when
method of the string
schema. The when
method accepts a list of names of dependent properties, then we pass an object which specifies that the name
attribute is not required when isUserAllergic
is true
.
anchorSummary
We've taken a look at an alternative approach to validating data in our apps. Now it's easier than ever to integrate with 3rd party libraries with mostly just Ember proxies standing on our way. I also find it beneficial to be able to use something like Yup for teams that work in many environments.
The complete source code used in this blog post can be found here: https://github.com/BobrImperator/emberfest-validations