in Projects, Programming, Technology

Isomorphic TypeScript Models with Yup and Objection

I’ve been working on a NERP stack implementation with the goal of re-using Typescript models in a ReactJS client app. This is useful if you have a full stack Javascript app, and want to iterate on your schema quickly. With shared models, if you add or a remove column in your database, the ramifications of that change are instantly visible in your client side code. Here is the pattern we came up with for re-using model schema on the client and server, while allowing each side to add the data they need.

These are code examples from a project with client (create-react-app), server (Express + Objection), and shared folders.

  1. Define shared schema using yup.

The “shared” schema is effectively the API schema. These are the fields exchanged in the API, common to client and server.

// none of these fields are required 
// since they are not actually set until saved to the database
export const sharedSchema = object({
  id: number()
    .integer()
    .notRequired(),
  createdAt: date().notRequired(),
  updatedAt: date().notRequired()
});
export type SharedData = InferType<typeof sharedSchema>;

Yup is cool because you can use it to validate objects either on the client or the server, and get Typescript type definitions magically. JSON Schema is another choice for this, but with the current tooling it is not possible to generate Typescript so easily.

OK, so those are the fields shared by all models. Let’s create an actual shared model.

// Shared Product
export const productSchema = sharedSchema.clone().shape({
  name: string().notRequired()
  // note: no users reference to avoid circular reference
});

// yup magically creates TypeScript type definition here
export type ProductData = InferType<typeof productSchema>;

// helper for converting from into shared format
// removes leaked data while preserving collections
const shareProduct = (p: ProductData) => {
  return productSchema.noUnknown().cast(p);
};

// Shared User
export const userSchema = sharedSchema.clone().shape({
  email: string().required(),
  displayName: string()
    .nullable()
    .notRequired(),
  photoUrl: string()
    .nullable()
    .notRequired(),
  products: array()
    .of(productSchema)
    .notRequired() // a collection!
});
export type UserData = InferType<typeof userSchema>;

const shareUser = (u: UserData) => {
  return userSchema.noUnknown().cast(u);
};

Note that InferType creates a TypeScript type, and that noUnknown strips extra fields while casts preserves collections and validates the schema. Server code will use shareUser before returning a user to the client, and vice versa.

2. Add shared helper functions

We didn’t declare any actual shared Javascript classes above. I had hoped to re-use functions as well as data, but since I also wanted to use the Objection ORM to inherit database persistence behaviors, I couldn’t inherit both shared methods and ORM methods. So, for now we’ll just define some shared functions that the client and server can use if they want.

// Shared Helpers
// Since the server can't inherit from a SharedUser class,
// we define shared helper functions for monkey patching later
export interface UserHelpers {
  isEmailValid: () => boolean;
  nameForDisplay: () => string;
}

export const getUserHelpers = (u: UserData): UserHelpers => {
  return {
    isEmailValid: (): boolean => {
      return (u.email || "").indexOf("@") > -1;
    },
    nameForDisplay: (): string => {
      return u.displayName || u.email.split("@")[0];
    }
  };
};

3. Define Client Classes

// Client Product
export class ClientProduct implements ProductData {
  id: number | undefined; // can't use "id?: number" (field is required, but may be undefined)
  createdAt: Date | undefined;
  updatedAt: Date | undefined;
  name: string | undefined;

  constructor(input: ProductData) {
    Object.assign(this, productSchema.noUnknown().cast(input));
  }
}

// Client User
export class ClientUser implements UserData, UserHelpers {
  id: number | undefined;
  createdAt: Date | undefined;
  updatedAt: Date | undefined;

  email!: string;
  displayName?: string;
  photoUrl?: string;
  products?: ClientProduct[];

  constructor(input: UserData) {
    Object.assign(this, userSchema.noUnknown().cast(input));
  }

  helpers = getUserHelpers(this);
  isEmailValid = this.helpers.isEmailValid;
  nameForDisplay = this.helpers.nameForDisplay;
}

There was an important gotcha around id: number | undefined. Using id? : number (the first thing I tried) doesn’t work. In this case id is required, but its value may be undefined. Whereas id?: number defines an optional field.

With regard to collections, I opted for a pattern where they are optional. So if user.products === undefined the collection hasn’t been loaded, but user.products === [] means there are none.

4. Define Server Models

In practice I would do this first, but I imagine many people reading this won’t be Objection users – and may do something different here.

export class ServerModel extends Model implements SharedData {
  id: number | undefined;
  createdAt: Date | undefined;
  updatedAt: Date | undefined;
}

// Server User - has additional data
export const userServerSchema = userSchema.clone().shape({
  passwordHash: string().notRequired()
});
type UserServerData = InferType<typeof userServerSchema>;

export class ServerUser extends ServerModel implements UserServerData {
  static tableName = "users";

  static fromShared(u: UserData): ServerUser {
    const user = new ServerUser();
    Object.assign(user, userSchema.noUnknown().cast(u));
    return user;
  }

  email: string = "";
  displayName?: string;
  photoUrl?: string;
  products?: ServerProduct[];
  passwordHash?: string;

  // no constructor (only the parameterless one provided by Objection)

  isEmailValid = getUserHelpers(this).isEmailValid;
  _nameForDisplay = getUserHelpers(this).nameForDisplay;

  get nameForDisplay(): string {
    return this._nameForDisplay();
  }
}

export class ServerProduct extends ServerModel implements ProductData {
  static tableName = "products";
  name: string | undefined;
}

Here we’ve aggregated id, createdAt, and updatedAt, added server-only fields, and patched our helper methods on.

There’s a bunch of fiddly syntax here, not to mention 2 separate build stacks and 3 sets of dependencies, but the end result is awesome. Simply renaming a field on a shared model will reveal all the React components using that value on the front-end, and back-end code using it.

Here’s a single Github gist with all the code in one guaranteed-to-compile blob.

What do you think?

This site uses Akismet to reduce spam. Learn how your comment data is processed.