Kio
@kio.sh
September 23, 2023
16 min read
4.7K views
Embracing Modern Data Validation in EmberJS
With Ember's shift away from Mixins and Computed Properties towards more standard JavaScript syntax and patterns, I wanted to detail how I wrote a new utility for validating Models, leveraging TypeScript, yup, and Tracked properties in order to leave behind old patterns and provide strict type-safety
Introduction
In an EmberJS codebase I've been working with, Computed Properties and Mixins were previously used to handle Model validations, via ember-cp-validations
. With Ember's shift away from Mixins and Computed Properties towards more standard JavaScript syntax and patterns, I wanted to detail how I wrote a new utility for validating Models, leveraging TypeScript, yup
, and Tracked properties in order to leave behind these old patterns and provide stricter type safety.
The Why: So Long, Computed Properties
Computed Properties have long been a staple in Ember, serving as a mechanism for defining reactive properties based on dependencies. They rely on explicit annotations to list dependencies, which has the tradeoff of making the code more verbose and less intuitive when compared to modern JavaScript patterns.
import EmberObject, { computed } from '@ember/object'; export default class Image extends EmberObject { width; height; @computed('width', 'height') get aspectRatio() { return this.get('width') / this.get('height'); } }
With that basic example in mind, our previous validation paradigm worked as follows: buildValidations
is called, provided with an object whose keys relate to property names on the Model to be validated, with their values being the result of calling the validator
function - given a string representing the validator's name, and provided options, it returns a Computed Property dependent on the Model key in question. The returned result from buildValidations
is an EmberObject Mixin, which can then be extended by the Model class to be validated:
import Model from '@ember-data/model'; import { buildValidations, validator } from 'ember-cp-validations'; const Validations = buildValidations({ someString: [ validator('presence', { presence: true, ignoreBlank: true, message: 'Enter a string' }), validator('length', { max: 255, message: 'String cannot exceed 255 characters' }) ], someNumber: validator('presence', { presence: true, message: 'Enter a number' }) }); export default class SomeModel extends Model.extend(Validations) {...}
Ember's shift away from Computed Properties and several other Ember Classic patterns, including Mixins, left us in an interesting spot. These Classic patterns aren't technically deprecated, but it's safe to say they will be soon, and their usage is discouraged. And that notwithstanding, they don't function quite right with Typescript - They work, but are difficult to use in otherwise-strictly-typed contexts and have issues when using Tracked primitives as dependant keys.
So how do Tracked properties come into this? As a modern replacement for Computed Properties, they simplify state management and address some flaws of Computed Properties. By annotating a property with the @tracked
decorator, Ember is informed that this particular field is likely to change over time and should trigger any dependent computations when it does. The result is a more straightforward, more explicit, less boilerplate-heavy approach to reactive state management.
import { tracked } from '@glimmer/tracking'; class Image { @tracked width: number; @tracked height: number; get aspectRatio() { return this.width / this.height; } // ... }
A caveat is that Computed Properties also handled caching - native JavaScript getters recompute on each access, which usually isn't ideal. The @cached
decorator from @glimmer/tracking
can be used to cache the getter's value and avoid unnecessary recomputation.
1
Moving away from Computed Properties also enables use of standard JavaScript syntax for property assignment and access, instead of EmberObject's .get()
/.set()
methods (mostly - the utility versions of these methods are still useful in some cases), and aligning classes more closely with native JavaScript patterns. Additionally, with Computed Properties, any property could theoretically be observed, potentially leading to unintended external dependencies or excessive re-computations. Tracked properties require explicit annotation, clearly delineating what properties are meant to be observed and reacted upon.
All that to say - there's many benefits from this switch ✨
Choosing a Library
The Options
Data validation libraries in the JavaScript and TypeScript ecosystem are abundant, catering to different paradigms, use-cases, and design philosophies. When it comes to type-safe validation for TypeScript specifically, there were several names I ended up looking into — zod , io-ts , and yup .
- zod: Known for robustness and its rich feature set,
zod
excels in static type inference and allows for the use of Union and Intersection types. - io-ts: Originating from the functional programming world,
io-ts
offers an extremely type-safe validation mechanism. The library is based in functional programming paradigms. - yup: Originally created for use in JavaScript,
yup
was later refactored to TypeScript. It offers good ease-of-use and relatively strong TypeScript support, albeit with some limitations in type inference and missing schema functionality.
Some other libraries focusing more on input validation are:
- validator.js: Great for simple, string-based validations, i.e. for form inputs, but it lacks the rich feature set and type safety required for more complex data structures.
- Joi: Initially built for Hapi.js but usable standalone,
Joi
is another popular validation library. It's highly extensible but, likevalidator.js
, less focused on type safety and more verbose compared toyup
orzod
. - class-validator: Works with TypeScript classes using decorators for validation logic. While tightly integrated with TypeScript, it couples validation logic directly with data models, which wasn't ideal for me.
There's also an entirely different class of libraries, like VeeValidate
and react-hook-form
which are framework-specific and concentrate on form validation. These can be useful for front-end validation for their respective UI library, but they don't offer the same flexibility as yup
or zod
when it comes to validating arbitrary data structures, nor do they usually offer type safety to the same degree.
Why 'Yup'?
So why yup
over the other libraries mentioned? A few factors: First, it offers a level of simplicity that makes introducing it to other developers on the team much easier. Its syntax is intuitive, and it provides pretty comprehensive documentation. Second, although it was initially developed for JavaScript, its TypeScript support has greatly matured over the years. Its type inference may not be perfect (that honour's given to zod
), but it's more than sufficient for most use-cases. Third, yup
provides a high degree of flexibility, allowing for the validation of various data structures, not just form inputs or DOM elements.
yup
stood out as a balanced choice for my use-case. It allows for easy schema definitions and validations, offers a fair level of type inference, and most importantly, doesn't require adopting a radically different programming paradigm. It is worth acknowledging its main shortcoming: schema type inference inaccuracies. Its type inference indicates that object properties are mandatory, when in actuality they're optional. In my opinion? The overall simplicity and developer experience offered outweighs this limitation. If you want truly strict & precise types, then zod
or io-ts
may be more compelling options.
Implementing the Utility
My objective was to offer type-safe validations for Models (or any other objects/classes, really), similar enough in structure to what ember-cp-validations
provided in order to reduce refactoring work. Besides switching out validation schema and Mixins in Models, I wanted it to be (more or less) a "drop-in" replacement.
Class Blueprint
The YupValidations
class takes in two type parameters, T extends FrameworkObject
, representing the context where validation happens (often, this will be an EmberData Model), and S extends ObjectShape
, representing a base yup
schema of an empty object. On instantiation, the class takes the calling context and the schema shape to be used, initializing the schema with yup.object().shape()
:
export default class YupValidations<T extends FrameworkObject, S extends ObjectShape> { context: T; shape: S; schema: SchemaType<S, T>; constructor(context: T, shape: S) { this.context = context; this.shape = shape; this.schema = object().shape(shape); // => SchemaType<S, T> const owner = getOwner(context); if (!owner) { Logger.error('YupValidations requires a FrameworkObject context with an owner. Got:', { context }); } else { // Use setOwner to associate the Validator instance as a DestroyableChild setOwner(this, owner); } } // ... }
Tracking Validation State
The @tracked
decorator is used in the class to track any current validation errors. TrackedObject
(from tracked-built-ins
) is needed for the validation state, since Glimmer's @tracked
decorator doesn't deep-track objects. This ensures any components accessing validations re-render when the validations change or are re-run.
validations: ValidationResult<S, T> = new TrackedObject({ isValid: true, attrs: {} as ValidationResult<S, T>['attrs'] }); @tracked error: ValidationError | null = null;
Typed Validation Results
The class employs some utility types to type validation results based on the schema shape. FieldErrors<T>
creates a mapping from each schema field to its validation messages, validity, and current/previous value. SchemaType<S, T>
represents the shape of the given schema, and ValidationResult<S, T>
represents the returned validation result.
type _<T> = T extends {} ? { [k in keyof T]: T[k]; } : T; type SchemaType<S extends ObjectShape, T extends AnyObject> = ObjectSchema< _<{} & TypeFromShape<S, T>>, T, _<{} & DefaultFromShape<S>>, '' >; type FieldErrors<T extends Schema> = { [key in keyof InferType<T>]: { messages: string[]; isValid: boolean; value?: InferType<T>[key]; previousValue?: InferType<T>[key]; }; }; type ValidationResult<S extends ObjectShape, T extends AnyObject> = { isValid: boolean; attrs: FieldErrors<SchemaType<S, T>>; };
Validating Data
The validate
method triggers validations. It validates all context properties and updates the tracked validations
property.
#validate = async () => { try { await this.schema.validate(this.#validationProperties, { strict: true, abortEarly: false, recursive: true, context: this.context }); this.error = null; return { isValid: true, attrs: this.fieldErrors }; } catch (e) { if (!(e as ValidationError)?.inner) { this.error = null; Logger.error('Encountered an unknown error while validating:', e); } else { this.error = e as ValidationError; } return { isValid: false, attrs: this.fieldErrors }; } }; validate = async () => { const validations = await this.#validate(); this.validations.isValid = validations.isValid; this.validations.attrs = validations.attrs; return validations; };
fieldErrors
is a getter which simply presents any existing ValidationError
s in a more manageable shape. Likewise for #validationProperties
.
@cached get fieldErrors() { const initialValue = Object.keys(this.shape).reduce( (acc, key) => { acc[key] = { messages: [], isValid: true, value: undefined, originalValue: undefined }; return acc; }, {} as FieldErrors<typeof this.schema> ); return (this.error?.inner ?? []).reduce((acc, validationError) => { const key = validationError?.params?.path as string | undefined; if (!key) { return acc; } if (!acc[key]?.messages) { acc[key] = { messages: [validationError.message] }; } else { acc[key]!.messages.push(validationError.message); } acc[key].isValid = false; acc[key].value = validationError.params?.value; acc[key].previousValue = validationError.params?.originalValue; return acc; }, initialValue); } get #validationProperties() { return Object.keys(this.shape).reduce( (acc, key) => { acc[key] = get(this.context, key); return acc; }, {} as { [key in keyof InferType<this['schema']>]: InferType<this['schema']>[key] } ); }
Putting it All Together: Usage Example
Now that we've looked at the internals of the utility, here's an example of it in action - suppose you have a Route where you're dealing with an instance of MyModel
and want to validate it before persisting any changes.
First, the model in question:
import Model, { attr } from '@ember-data/model'; import { number, string } from 'yup'; import Validator from 'app/utils/validations'; declare module 'ember-data/types/registries/model' { export default interface ModelRegistry { 'my-model': MyModel; } } const validations = { someString: string() .min(12, 'Enter a valid string') .max(13, 'Enter a valid string') .required('Enter a string'), someNumber: number().required('Enter a number') }; export default class MyModel extends Model { validator = new Validator(this, validations); @attr('string') someString!: string; @attr('number') someNumber!: number; }
Now, let's call validate()
on its Validator instance:
import Route from '@ember/routing/route'; import { action } from '@ember/object'; import { service } from '@ember/service'; import type { StoreService } from '@ember-data/store'; export default class MyRoute extends Route { @service declare store: StoreService; model(params) { return { someRecord: this.store.findRecord('my-model', { id: params.id }) }; } @action async save() { const { isValid, attrs } = await this.model.someRecord.validator.validate(); if (isValid) { return this.model.someRecord.save(); } else { // All Validated attributes are present on `attrs` attrs.someString // => { messages: string[]; isValid: boolean; value?: string; previousValue?: string } return false; } } }
Wrapping Up
Building this out, I had a chance to dive much deeper into TypeScript utility types and generics than previously. I'm quite happy with the end result; a type-safe validation utility which can be used across our Ember application with confidence. Swapping out Computed Properties for Tracked properties isn't just a box-ticking exercise; it's a necessary step towards making the codebase more maintainable and future-proof.
My choice to use yup
over other validation libraries came after weighing the trade-offs for this specific use-case. Ultimately, I think any of the mentioned libraries would be great fits, it just came down to my preference for yup
's syntax.
I hope sharing this experience offers some valuable insights or ideas for your own projects, whether you're working with Ember or any other framework. If you have questions or want to discuss any part of this in more detail (cough cough, or if I made any mistakes), feel free to reach out !