@olo/principled-forms
is a library for principled form handling, ready to use as the form handling logic for your front-end app in any JavaScript framework.
[API Docs](TODO)
Add the library to your project.
With yarn
:
yarn add @olo/principled-forms
With npm
:
npm install @olo/principled-forms
Form handling is inherently complicated. At a minimum, every form has to account for:
- unvalidated, invalid, and valid fields – and the corresponding notion of what constitutes “validity” for a given field
- correct handling of optional and required fields
- different kinds of fields – checkboxes, radio buttons, text input, range inputs, etc.
- the relationship between the field validations and form validation and submission
- the relationship of form data to the persistence layer
- maintaining accessibility with different presentation styles
@olo/principled-forms
is not an attempt to abstract over that complexity but to isolate it. (Every attempt to hide form complexity ultimately leaks!)
The goal is to provide coherent, well-tested, and carefully type-checked tools for dealing with the inherent complexity of forms, while leaving you assemble those answers in the context of your own forms. It is not the UI layer, it’s the set of tools and patterns—as many of them as possible enforced by TypeScript’s type system!—for making sure the data behind your UI layer makes sense. Here’s that list again, with the parts this library handles bolded:
- unvalidated, invalid, and valid fields – and the corresponding notion of what constitutes “validity” for a given field
- correct handling of optional and required fields
- different kinds of fields – checkboxes, radio buttons, text input, range inputs, etc.
- **the relationship between the field validations and form validation **and submission
- the relationship of form data to the persistence layer
- maintaining accessibility with different presentation styles
@olo/principled-forms
is designed to be consumed by the UI layer in your app – whether that’s vanilla JS, old-school jQuery, or a brand new Ember, React, or Vue app. For a reference implementation which integrates this library and carefully addresses the accessibility concerns, see the associated Ember component library, @olo/ember-principled-forms
! You can drop that into an existing Ember application and start using this approach today.
Let’s assume a fairly typical model of a user in an application, which is stored in our data store with this type definition:
type User = {
email: string;
ageInYears: number;
phoneNumber?: string;
name?: string;
};
The contract for the user requires them to have an email address (presumably so we can contact them) and an age (so we can avoid breaking the law in some jurisdictions), and allows them to supply a name (which is optional because many people in the world don’t actually have a single name they use in all circumstances) or a phone number (perhaps for texting them information).
Our form handling should account for the fact that some of these pieces of data are required to create a user, and some are not. It should also only allow the right kinds of data to go into those fields. Enter @olo/principled-forms
! Let’s assume that the type definition for a User
lives in our application at models/user
, and see how we’d define a principled form using the library.
First, we’ll define the transformation from a persistence model to our form model, using the FromModel<T>
type to help us write the function correctly:
import Form, { FromModel } from '@olo/principled-forms/form';
import Field from '@olo/principled-forms/field';
import User from 'models/user';
const baseUserFormModel: Form<User> = {
email: Field.required(),
ageInYears: Field.required(),
name: Field.optional(),
phoneNumber: Field.optional(),
};
// TODO: extract for later example
const userFormModelFromUser: FromModel<User> = (user) => ({
email: Field.required({ value: user.email }),
ageInYears: Field.required({ value: user.ageInYears }),
name: Field.optional({ value: user.name }),
phoneNumber: Field.optional({ value: user.phoneNumber }),
});
Then we can use that to take the backing persistence model value and turn it into a form model:
// Note that the `phoneNumber` field is absent entirely.
let user: User = {
email: 'hello@example.com',
ageInYears: 24,
name: 'John Doe',
};
// Has the type `Form<User>`
let userFormModel = userFormModelFromUser(user);
This is the most minimal correct setup for a form model we could have. The userFormModel
is now a Form<User>
and its validity
property is one of the Validity
values from @olo/principled-forms/validity
: Unvalidated
, Invalid
, or Valid
. In this case, because the persistence store had valid data, userFormModel.validity
would be Validity.Valid
. Had the persistence store contained invalid data (for example, if it were missing the email
value), the form
Importantly, if we used Field.optional()
for email
or Field.required()
for phoneNumber
, this wouldn’t type-check. The FromModel<T>
type checks whether the fields on the persistence model type T
are optional and requires that you you make a RequiredField
using Field.required
for non-optional fields and an OptionalField
using Field.optional
. (Bonus: if you’re using the True Myth Maybe
type, it maps those to OptionalField
s as well. The types below all include the places where the True Myth Maybe
type is allowed or inferred from.)
But what exactly is one of these RequiredField
or OptionalField
types? In short: a Field
type is lightweight wrapper that contains:
- the value of the field,
value
- whether or not it’s required (thus the
Required
and Optional
variants)
- its current
Validity
(the same validity type as the Form<User>
has; that’s not a coincidence!)
- a list of its validators
The Field.required()
and Field.optional()
functions take a hash of options which allow us to specify all of these properties other than the validity
, which is not something we’re allowed to set ourselves. The Type
here is the form field type: 'text'
, 'email'
, 'number'
, etc.
type RequiredFieldConfig<T> = {
type?: Type;
value?: T;
validators?: Array<Validator<T>>;
messageIfMissing?: string;
};
type OptionalFieldConfig<T> = {
type?: Type;
value?: T | Maybe<T>;
validators?: Array<Validator<T>>;
}
The email
and name
fields we defined provide good working examples:
email
:
- It can have any
value
at all that is of type string
, even if that string value is "gobbledygook"
and therefore totally invalid.
- The field is a
RequiredField
instance. That means that when we validate it, if the value
is undefined
, null
, or an empty string, its validity will be Validity.Invalid
.
- It has no validators, so it will only be checked for its presence or absence.
name
:
- Like
email
, it can have any value
of type string
.
- The field is an
OptionalField
instance. That means that when we validate it, if its value
is undefined
, null
, or an empty string, its validity will be Validity.Unvalidated
. (More on this below.)
- It has no validators, so it will only be checked for its presence or absence.
We can make these checks much more helpful by including some validators for each of them. A validator is just a function which matches this type definition:
type Validator<T> = (value: T) => Validated;
where Validated
is an instance of three types (slightly different from the Validity
type discussed elsewhere, which is just used internally to distinguish these types): Unvalidated
, Invalid
, or Valid
. Unvalidated
and Valid
are empty types which only serve to mark what validity state something is in; Invalid
also includes the reason the state is invalid as a string.
There are a number of validators supplied with the library, but you can also write your own – as long as they conform to that API, they’ll work (and they won’t type-check if they don’t match that API!).
Here, we might decide that we want to use a regular expression to check that the email
field at least has the right shape, and that a name is at least 5 characters long (who knows why). We could define the fields with validations, using the regex
and minLength
helpers from the validators
module:
import Field from '@olo/principled-forms/field';
import { minLength, regex } from '@olo/principled-forms/validators';
let email: Field<string> = Field.required({
validators: [regex(/.+@.+\..+/)],
});
let name: Field<string> = Field.optional({
validators: [minLength(5)],
});
When you validate a field, the resulting validity is the result of checking the value
of the field against each of the validators
in the array – the result will be an array of validities, where each one is either unvalidated, invalid (with an explanation of why it’s invalid), or valid. The whole field’s validity is:
Unvalidated
if every validity in that resulting array is Unvalidated
Invalid
if any validity in the resulting array is Invalid
Valid
if every validity in the resulting array is Valid
or if it is an optional field and the value
is empty
You can have as many of these validators in the array as you want. Be careful with this power, though: you can build nonsense combinations that will never be valid, like [minLength(5), maxLength(3)]
!
For a very simple example of defining your own validators, let’s build two new helpers. For the first, we’ll just write a function which requires that its string argument be a string including the letter ‘v’. For the second, let’s assume you need to set a minimum and maximum length for a field—to match what your database supports, or something like that. (We could do that by combining the minValue
and maxValue
helpers, but where’s the fun-and-learning in that?)
import Field from '@olo/principled-forms/field';
import { Valid, Invalid, Validator } from '@olo/principled-forms/validity';
let includesV: Validator<string> = (value) => value.includes('v');
let validatedString = Field.required({ validators: [includesV] });
function inclusiveRange(min: number, max: number): Validator<string> {
return (value) => {
if (value < min) {
return Invalid.because(value cannot be less than ${min});
} else if (value > max) {
return Invalid.because(value cannot be greater than ${max});
} else {
return Valid.create();
}
};
}
let validatedNumber: Field.required({ validators: [inclusiveRange(2, 7)] });
Notice that we can constructor a validator in however simple or complex a fashion as we need, as long as you end up with a Validator
over the appropriate type (string
, number
, etc.). Most of the time, though, you’re probably going to end up writing your custom helpers as higher-order functions which return validators.
Once you have a field defined this way, you can validate it:
import Field from '@olo/principled-forms/field';
import { minLength } from '@olo/principled-forms/validity';
// Both are `Unvalidated`
const moreThan5CharsReq = Field.required({ validators: [minLength(5)] });
const moreThan5CharsOpt = Field.optional({ validators: [minLength(5)] });
// => `Invalid`
const validatedReq = Field.validate(moreThan5CharsReq);
// => `Valid`
const validatedOpt = Field.validate(moreThan5CharsOpt);
Here, the results are both still Field
s, but with their validity checked given their values – they started out as Unvalidated
, but a required field with no value is Invalid
after validation; while an optional field with no value is Valid
, because “empty” is indeed a valid state for it to be in. Also, it’s important to note that the original variables (moreThan5CharsReq
and moreThan5CharsOpt
) are unchanged: we make a copy with the newly calculated validity, rather than mutating the old one in place.
Why do fields start as Unvalidated
? Because when a user has not yet interacted with a form field, it should not be considered “invalid” – an empty field may be invalid in the broad scheme of whether a form is allowed to be submitted, for example, but it’s a perfectly valid starting state for the form. On the one hand, the user certainly shouldn’t be seeing error messages about it; on the other hand, we can’t let them submit a form with required fields which haven’t been validated! We capture this with the Unvalidated
state.
So given a field with a current value, how would you change the field’s value
Now that we have a Form<User>
, we can use the validation rules from the library to validate it:
import { validate } from '@olo/principled-forms/form';
let validatedUser = validate(userFormModel);
Now, of course, this isn’t all that helpful: we also need two more pieces:
- validation of the actual values of the fields, not just checking whether they’re present or not
- updates to the fields themselves over time, as the user interacts with the field
The Field.required()
and Field.optional()
invocations we saw above have a rich API (which is identical between the required
and optional
functions) for specifying how the field to be validated. The library also supplies a handful of convenient helper functions for common validation patterns. For example, let’s say that we wanted to specify that the email
field must meet the basic pattern of an email address, the user must be at least 13 years old , the name
field has to be at least 5 characters long (who knows why), and the phone number should match a U.S. phone number pattern.
import Field from '@olo/principled-forms/field';
import Form from '@olo/principled-forms/form';
import {
minLength,
minValue,
regex,
} from '@olo/principled-forms/validators';
const userFormModel: Form<User> = {
email: Field.required({ validators: [regex(/^.+@.\..+/)] }),
dateOfBirth: Field.required({ validators: [minValue(13)] }),
name: Field.optional({ validators: [minLength(5)] }),
phoneNumber: Field.optional({
validators: [regex(/^(1-)?\d{3}-\d{3}\d{4}/)],
}),
};
Now not only are the required or optional fields checked for presence or absence, but their values are validated whenever the form is.
There are four fundamental ideas in the library:
-
A form model is distinct from, though derived from, a persistence model. When we’re operating on a form, we have to keep track of its validity. For the most part, in our persistence layer – whether that’s a server-side database, a Redux store, or something else – we usually assume (and nearly always hope) we have either valid data or no data, but not invalid data. Therefore, every form has its own form model, distinct from the persistence model. A form model can always be derived from its corresponding persistence model through a pure function, and vice versa—and the transformation between the two can by type-checked.
-
The validity of a form is just the composition of the validity of all of its fields. That is: if all the fields in a form are in a valid state, the form is valid, but if any of the fields in a form are in an invalid state, the form as a whole is also invalid. This means that the Form.validate
function provided by the library can validate any Form
model defined in terms of the Field
model types from the library.
-
A form field may be in three states: unvalidated, invalid, or valid. Many approaches to form handling treat a given field as invalid or valid, but in fact form fields start out (and may remain) unvalidated. Distinguishing between these three states allows for complex form behavior to emerge clearly and cleanly – for example, validating an email address while the user is typing it in, without showing an “invalid email” message just as soon as the user types the first letter of their email!
-
The required-vs.-optional rule for a field is distinct from other validation rules. It is tempting to treat whether a field is required or not as simply being the same kind of rule as other validation rules – the minimum value for a number or a regex pattern for a string, for example – but in fact the required or optional value for a form determines how to apply the other rules. For example: an optional field is not invalid if it is empty, even though empty values (undefined
or ""
) may not match against whatever other validation rules are specified for the field.
Every design decision in the library falls out of these four ideas.
There are also four basic levels of abstraction in the library:
- form models
- field models
- validity states
- validators
The library is designed so that you primarily operate in terms of the provided functions and types from the form
, field
, and validity
modules, along with validators for each field. A number of default validators are supplied, but you are also encouraged to write your own – they have a very simple API contract (which we’ll cover below, as well as in the API Docs).
One final note on the library design: it’s written in TypeScript, so whether or not you’re using TypeScript yourself, you may get benefits from your editor – VS Code, JetBrains IDEs, and others are able to use TypeScript’s type definition files to provide smart editing completion. The library is optimized for TypeScript consumption, though: it leans hard on the most advanced features in TypeScript’s type system, so you can know if you’ve set up your form model correctly. For an example of that, let’s jump in.