Skip to content

Instantly share code, notes, and snippets.

@adamloving

adamloving/example.ts

Last active Nov 24, 2020
Embed
What would you like to do?
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

This comment has been minimized.

Copy link
Owner Author

@adamloving 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
You can’t perform that action at this time.