Skip to content

Instantly share code, notes, and snippets.

@adamloving
Last active July 27, 2021 07:32
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save adamloving/a94af4ff5e67ff03413d9c1f21f8b8e0 to your computer and use it in GitHub Desktop.
Save adamloving/a94af4ff5e67ff03413d9c1f21f8b8e0 to your computer and use it in GitHub Desktop.
Isomorphic (shared) Typescript models for full stack Node Express React Postgres projects #NERP
import { array, object, number, string, date, InferType } from "yup";
import { Model } from "objection";
// Shared
// Shared API data schema (objects shared between client and server)
export const sharedSchema = object({
// none of these are required since not set until save (but they are also not nullable)
id: number()
.integer()
.notRequired(),
createdAt: date().notRequired(),
updatedAt: date().notRequired()
});
export type SharedData = InferType<typeof sharedSchema>;
// Shared Product
export const productSchema = sharedSchema.clone().shape({
name: string().notRequired()
// note: no user reference to avoid circular reference
});
export type ProductData = InferType<typeof productSchema>;
// helper for converting from either client or server 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>; // rename "SharedUser"
const shareUser = (u: UserData) => {
return userSchema.noUnknown().cast(u);
};
// Shared Helpers
// Since the server can't inherit from a SharedUser class,
// we define shared helper functions for monkey patching
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];
}
};
};
// 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;
}
// Server User
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;
}
// Tests ---
// Convert client -> shared -> JSON -> shared -> server
const cu = new ClientUser({
email: "test@test.com"
});
if (!cu.isEmailValid()) {
throw new Error("Email not valid or helper didn't work");
}
const userData = shareUser(cu);
const serializedUserData = JSON.stringify(userData);
const deserializedUserData = JSON.parse(serializedUserData);
const su = ServerUser.fromShared(deserializedUserData);
if (!su.email) {
throw "No Server User email";
}
// Go the other way, and add collection (pretend these were loaded from database)
const su1 = new ServerUser();
const sp = new ServerProduct();
su1.id = 1;
su1.email = "test1@test.com";
su1.products = [sp];
sp.id = 1;
sp.name = "Product 1";
const sud = JSON.stringify(shareUser(su1));
const dud = JSON.parse(sud);
const cu1 = new ClientUser(dud); // can use constructor going this way
if (cu1.email !== "test1@test.com") {
throw new Error("Email missing");
}
if (!cu1.products) {
throw new Error("Products missing");
}
if (cu1.products?.[0].name !== "Product 1") {
throw new Error("Product missing");
}
@adamloving
Copy link
Author

adamloving commented Jun 25, 2020

  1. get rid of SharedModel (since ServerModel can't inherit it anyway). Replace with "UserHelpers" which both client & server can use.
  2. add products collection and make sure that it works both ways. Having yup take care of the nesting is nice!
  3. Tweak declarations to make typescript, yup, & database schema copacetic.
  4. Introduce shareX() pattern to support collections and prevent leakage (just a convenience over userSchema.noUnknown.cast)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment