Last active
July 27, 2021 07:32
-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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: "[email protected]" | |
}); | |
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 = "[email protected]"; | |
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 !== "[email protected]") { | |
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"); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
products
collection and make sure that it works both ways. Having yup take care of the nesting is nice!shareX()
pattern to support collections and prevent leakage (just a convenience over userSchema.noUnknown.cast)