The Theory
By using types correctly, we can capture our business logic in the type system itself.
During some of my routine scavenges across internet blogs and resources, I ran across a concept known as type-driven development and was completely dumbstruck. Is it possible to code types in such a way as to prevent invalid states during compile time? For those who may not have heard the term before, type-driven development is a development strategy in which you express the business rules into the type system itself. The end result is that the code becomes much more robust, self-documenting and easier to reason about. Function signatures become explicit, asking for what they truly need. All of this together makes it much easier to mitigate bugs or misunderstandings in what the code does since it only allows valid states to be used.
For this sample project, we will solve a common business request: form validation. In this form we will gather first name, last name and email address from the user. The validation requirements will be:
- First name, last name and email address must all be specified before the “Save” button will enable
- Email must be properly formatted and any criteria not met should be displayed to the user while input is given
To start with, we will need 3 distinct types to represent the different states an email could be in: initial for when the user first arrives at the page, invalid for when a user has given an improperly formatted email and valid for when everything looks good.
An easy way to think about this is to imagine your application being a state machine. Each unique state should be a type and the transitions are functions.
import validateEmail from '../utilities/validateEmail'; export type Email = InvalidEmail | ValidEmail | InitialEmail; export class InvalidEmail { public readonly key = 'Invalid'; constructor(public readonly address: string, public readonly errors: string[]) { } }; export class InitialEmail { public readonly key = 'Initial'; public readonly address = ''; }; export class ValidEmail { public static create(possibleEmail : string) : Email { return validateEmail(possibleEmail) .matchWith<Email>({ Failure: errors => new InvalidEmail(possibleEmail, errors.value), Success: () => new ValidEmail(possibleEmail), }); } public readonly key = 'Valid'; private constructor(public readonly address : string) { } }
Types as a State Machine
Expressive type can improve code readability and catch edge cases for the developer.
As you can see above, ValidEmail’s can only be created given the correct input. This is achieved by making the constructor of ValidEmail private, forcing all consumers to use the static create function. This is an important detail because the create function could return a ValidEmail or InvalidEmail depending on if the formatting requirements are met. The benefit of this approach is that it forces any calling code to handle both cases, preventing mistakes from being made and making the code much easier to read. Now normally in F# and other functional languages, the consuming function would simply pattern match over the union given. However, since TypeScript doesn’t have pattern matching, we will have to use tagged unions instead. This forces us to include a unique flag constant in each Email type. Despite the inconvenience, we now gain the benefit that any code using our TypeScript union will throw a compile-time error if a case is unaccounted for. In order to generate the options for our match function, we can take advantage of TypeScript’s nifty conditional types feature:
type ReturnFunction<TInput, TReturn> = (param : TInput) => TReturn; type DiscriminateUnion<T, K extends keyof T, V extends T[K], TReturn> = T extends Record<K, V> ? ReturnFunction<T, TReturn> : never export type MapDiscriminatedUnion<T extends Record<K, string>, K extends keyof T, TReturn> = { [V in T[K]]: DiscriminateUnion<T, K, V, TReturn> };
Which in turn allows our match functions to be written like this:
import validateEmail from '../utilities/validateEmail'; import { MapDiscriminatedUnion } from './MapDiscriminatedUnion'; type EmailMatchOptions<TReturn> = MapDiscriminatedUnion<Email, 'key', TReturn>; export type Email = InvalidEmail | ValidEmail | InitialEmail; export function matchEmail<TReturn>(email : Email, options: EmailMatchOptions<TReturn>) { switch(email.key) { case "Initial": return options.Initial(email); case "Invalid": return options.Invalid(email); case "Valid": return options.Valid(email); } } export class InvalidEmail { public readonly key = 'Invalid'; constructor(public readonly address: string, public readonly errors: string[]) { } }; export class InitialEmail { public readonly key = 'Initial'; public readonly address = ''; }; export class ValidEmail { public static create(possibleEmail : string) : Email { return validateEmail(possibleEmail) .matchWith<Email>({ Failure: errors => new InvalidEmail(possibleEmail, errors.value), Success: () => new ValidEmail(possibleEmail), }); } public readonly key = 'Valid'; private constructor(public readonly address : string) { }
This also has the benefit that anytime our union types are updated, all of our match functions will generate a compile-time error and require that the missing case is handled. Neat! We can now implement our name and user types in a similar fashion:
import { MapDiscriminatedUnion } from "./MapDiscriminatedUnion"; export type NameInformation = CompleteNameInformation | IncompleteNameInformation; type NameOptions<TReturn> = MapDiscriminatedUnion<NameInformation, 'key', TReturn>; export function matchName<TReturn>(name : NameInformation, options : NameOptions<TReturn>) { switch (name.key) { case "Incomplete": return options.Incomplete(name); case "Complete": return options.Complete(name); } } export class IncompleteNameInformation { public readonly key = 'Incomplete'; public constructor(public readonly firstName: string, public readonly lastName: string) {} } export class CompleteNameInformation { public static create(firstName: string, lastName: string) { return firstName && lastName ? new CompleteNameInformation(firstName, lastName) : new IncompleteNameInformation(firstName, lastName); } public readonly key = 'Complete'; private constructor(public readonly firstName: string, public readonly lastName: string) {}
import { Email, matchEmail, ValidEmail } from "./Email"; import { MapDiscriminatedUnion } from "./MapDiscriminatedUnion"; import { CompleteNameInformation, matchName, NameInformation } from './NameInformation'; type UserMatchOptions<TReturn> = MapDiscriminatedUnion<User, 'key', TReturn>; export type User = IncompleteUser | CompleteUser | SavedUser; export function matchUser<TReturn>(user : User, options : UserMatchOptions<TReturn>) { switch (user.key) { case "Complete": return options.Complete(user); case "Incomplete": return options.Incomplete(user); case "Saved": return options.Saved(user); } } export class IncompleteUser { public readonly key = 'Incomplete'; constructor(public readonly name: NameInformation, public readonly email: Email) { } public match<TReturn>(options : UserMatchOptions<TReturn>) { return options.Incomplete(this); } } export class CompleteUser { public static create(nameInformation : NameInformation, email : Email) : User { return matchEmail(email, { Initial: (initialEmail) => new IncompleteUser(nameInformation, initialEmail), Invalid: (invalidEmail) => new IncompleteUser(nameInformation, invalidEmail), Valid: validEmail => matchName<User>(nameInformation, { Complete: completeName => new CompleteUser(completeName, validEmail), Incomplete: incompleteName => new IncompleteUser(incompleteName, validEmail), }), }) } public readonly key = 'Complete'; private constructor(public readonly name: CompleteNameInformation, public readonly email: ValidEmail) { } } export class SavedUser { public readonly key = 'Saved'; public constructor(public readonly name: CompleteNameInformation, public readonly email: ValidEmail, public readonly userId: number) { } }
Based on the code above, a CompleteUser can now be definitively expressed as a combination of a CompleteName and a ValidEmail.
Now that the types are in order, the reducer and action creators became extremely trivial:
import { Dispatch } from 'redux'; import { saveUser } from '../api/userApi'; import { InitialEmail, ValidEmail } from '../types/Email'; import { IncompleteUser, User } from '../types/User'; import { createAction } from '../utilities/actionCreator'; import { CompleteNameInformation, IncompleteNameInformation } from './../types/NameInformation'; import { CompleteUser, matchUser, SavedUser } from './../types/User'; import { GetState } from './index'; export enum ActionTypes { CHANGE_EMAIL = 'user/changeEmail', CHANGE_NAME = 'user/changeName', SAVE_SUCCESSS = 'user/saveSuccessful', SAVE_USER = 'user/save', }; export const changeName = (firstName : string, lastName : string) => createAction(ActionTypes.CHANGE_NAME, CompleteNameInformation.create(firstName, lastName)); export const changeEmail = (email: string) => createAction(ActionTypes.CHANGE_EMAIL, ValidEmail.create(email)); const saveSuccessful = (user : SavedUser) => createAction(ActionTypes.SAVE_SUCCESSS, user); export const save = () => (dispatch : Dispatch, getState : GetState) => { return matchUser<Promise<User>>(getState().user, { Complete: (completeUser) => saveUser(completeUser).then((savedUser) => { dispatch(saveSuccessful(savedUser)); return Promise.resolve(savedUser); }), Incomplete: (u) => Promise.resolve(u), Saved: (u) => Promise.resolve(u), }); } type UserReducerState = User; type UserActions = ReturnType<typeof changeName> | ReturnType<typeof saveSuccessful> | ReturnType<typeof changeEmail>; const DEFAULT_STATE : UserReducerState = new IncompleteUser(new IncompleteNameInformation('', ''), new InitialEmail()); export default (state: UserReducerState = DEFAULT_STATE, action: UserActions) : UserReducerState => { switch(action.type) { case ActionTypes.CHANGE_NAME: return CompleteUser.create(action.payload, state.email); case ActionTypes.CHANGE_EMAIL: return CompleteUser.create(state.name, action.payload); case ActionTypes.SAVE_SUCCESSS: return action.payload; default: return state; } }
Additionally, in our UI code we can again take advantage of the match functions:
import { Button, FormControl, FormHelperText, Icon, Input, InputAdornment, InputLabel, Paper, TextField } from "@material-ui/core"; import Check from '@material-ui/icons/Check'; import * as React from "react"; import { connect } from "react-redux"; import { ReducerState } from "./redux"; import * as UserActions from "./redux/userReducer"; import { matchEmail } from "./types/Email"; import { User } from "./types/User"; import { Field, FieldWrapper, UserContainer } from "./User.styled"; type UserProps = { user: User; }; function withEventValue<TReturnType>(func: (input: string) => TReturnType) { return (event: React.ChangeEvent<HTMLInputElement>) => func(event.target.value); } const User = (props: UserProps & typeof UserActions) => ( <UserContainer> <Paper> <FieldWrapper> <Field> <TextField label="First Name" value={props.user.name.firstName} onChange={withEventValue(firstName => props.changeName(firstName, props.user.name.lastName) )} /> </Field> <Field> <TextField label="Last Name" value={props.user.name.lastName} onChange={withEventValue(lastName => props.changeName(props.user.name.firstName, lastName) )} /> </Field> <Field> <FormControl> <InputLabel htmlFor="email">Email Address</InputLabel> <Input id="email" type="text" value={props.user.email.address} onChange={withEventValue(props.changeEmail)} endAdornment={ props.user.email.key === "Valid" ? <InputAdornment position="end"> <Icon> <Check style={{ color: "green" }} /> </Icon> </InputAdornment> : '' } /> {matchEmail<JSX.Element | string>(props.user.email, { Initial: () => "", Invalid: incompleteEmail => ( <React.Fragment> {incompleteEmail.errors.map(error => ( <FormHelperText error>{error}</FormHelperText> ))} </React.Fragment> ), Valid: () => "" })} </FormControl> </Field> <Button variant="contained" onClick={props.save}>Save</Button> </FieldWrapper> </Paper> </UserContainer> ); const mapStateToProps = (state: ReducerState) => ({ user: state.user }); export default connect( mapStateToProps, { ...UserActions } )(User);
Final Product
By combiing all of the these techniques, we can see the Redux store moving through the state machine in real time.
Here is the final result:
All in all, I would say that with the addition of utility functions in TypeScript 2.8 and conditional types, a type-driven approach is achievable in TypeScript . With the exception of deriving the match “options”, everything else was extremely comparable to what other functional languages would implement. In my current job, we do not use TypeScript, so finding and anticipating errors in object graph changes can be real nail biters at times. From what I’ve seen, this approach helps tremendously in solving that problem.
If you’re interested in downloading and playing around with the code, it can be found at: https://github.com/drewcarver/type-driven-development
Additionally, I would be remiss if I did not include a link to Scott Wlachin’s excellent series on type-driven development in F# series: Designing With Types