TypeScript & Javascript ORM Exploration

Fantasy City Skyline

In the never-ending quest for the perfect web stack, I had some time the last few weeks to explore new developments in the Javascript world.

Goals & Requirements

In the spirit of setting best practices for a startup, here are some of the features I’m looking for in a web stack. Assuming node on the front-end (React) and back-end (Express), is there an efficient stack for saving data to a relational database (Postgres)?

Migrations

Because schema changes quickly, I’d like a nice migration workflow. I like the Rails way. Database changes are scripted in a repeatable way. The expectation is that the app migrations create the tables and columns necessary for the app. The database may not be exclusively used for our app though, so the schema should not necessarily be solely derived from the models.

It would be nice if a migration could be derived from a typescript class… but that’s probably code-generator territory (and as I said, the database schema isn’t necessarily going to match the JS model interface).

Static Type Checking

We’d like to use TypeScript to catch type issues earlier… especially for the models served through the shared API. JSON schema could also be used to define the types returned by the API, but if both sides of the API are TypeScript Javascript, it seems like we won’t need it.

Isomorphic Models

Re-using models on client and server would be nice. For speed, we’d actually tightly couple front-end and back-end code with model object definitions in Javascript. Ideally we’d like a typed API and Javascript on both the front and back-end.

ActiveRecord Patterns

This is another convenience preference. I don’t want a separate query execution object (and connection manager), want it baked in to the server side models.

Simple CRUD and basic support for simple object graphs

I don’t want to go crazy with SQL generated queries (the usual criticism for using an ORM). I just want user.save(), and user.products, and User.findBySQL(...). I’m not looking for GraphQL style object-tree updates. I’ll implement custom APIs if the needs of the client exceed a simple REST-API.

Findings (current pattern)

I evaluated typeorm, but ended up going with Objection.js on top of Knex. Here’s what the workflow looks like.

Create a (TypeScript) Migration

$ knex migrate:make migration_name -x ts

See Knex Migrations for migration syntax. They’re not derived from anything you have to write them.

Run Migrations

$ knex migrate:latest

Define your shared model in typescript

interface User {
  id: Number;
  email: string;
  createdAt: Date;
  updatedAt: Date;
}

// When serialized to JSON, dates are converted to strings, so we define an interface for that.
interface UserForJSON {
  id: Number;
  email: string;
  createdAt: string;
  updatedAt: string;
}

export class SharedUser extends User {
  // methods for converting to and from JSON can be shared between
  // client and server
}

Additionally, shared methods like data validation and conversion can be defined on this class which is included by both client and server. The shared class can be subclassed for client and server.

Define a Server Model Subclass

For the server ORM, I ruled out typeorm because defining a model requires either using decorators (too much extra annotation, and decorators aren’t officially part of Javascript yet), or a plain old JS object to describe the schema (which is redundant if you’re using typescript). Typeorm supports a nice ActiveRecord-style API, but you only get that with the decorators, which by the way also make your class incompatible with client side use (there’s no way to inherit your shared model then decorate without redefining everything).

Both Knex and Objection support typescript. Knex has a decent syntax for saving data.

db<User>("users").returning("*").insert({ ... })

Objection.js is an ORM layer on top of Knex that alows for a minimalist model declaration (no decorators or non-typescript-declaration). It also attaches ORM methods to the actual model (not ActiveRecord-style, but similar to Knex).

const p1 = new Product()
const p2: Product = await Product.query().insert(p1).returning("*");

For an Objection server model you can inherit from your shared TypeScript interface and the Objection Model class by using TypeScript’s “declaration merging.” Why can I sleep at night with this hack, when I don’t want to use decorators? Because it’s less code, I guess.

// inherits createdAt and updatedAt, and adds an extra field
// knexSnakeCaseMappers converts somethingSecret to something_secret
interface ProductForDatabase extends Product {
  somethingSecret: string;
}

export class ProductServerModel extends Model {
  // assuming your ID is called "id",
  // the only thing you need to tell Objection is the table name.
  static get tableName() {
    return "products";
  }
}

// typescript "declaration merging" combines our interface with class
export interface ProductServerModel extends Model, ProductForDatabase {}
applyMixins(ProductServerModel, [Model]);

Learnings

I think this is a pretty workable pattern, but really verbose when you compare it to Rails. Luckily TypeScript is somewhat optional, so I’ll probably only use it for key interfaces. My key takeaways:

  • Isomorphic client and server types may not be what I really want. Client and server models have different fields and, the JSON types are also different (Dates must be converted to strings).
  • TypeScript is widely supported, but feels sort-of-like coffee script – a nice hack that will probably be obsolete in 4-5 years.
  • TypeScript class and interface definitions are not available at runtime. I’m less sold on strong typing than I was in the beginning because I can see how TypeScript (while well supported) is just a transpile time feature. This makes it non-obvious to do things like convert a model instance to and from the JSON serializable type (because you don’t know the types of the fields at runtime).
  • Because of that, JSON Schema may actually be useful for defining the types in for generating migrations, and scaffolding forms. However, I’ve heard JSON Schema is “inauthorable by humans” and found the Javascript libraries for generating forms very immature.