FAQ
Why use Entities & Mutable Classes?
See Why Entities, and the "Why Classes" and "Why Mutability" sections.
A tldr is that we think mutable entities is the most ergonomic way to indicate "this how you would like the world to look" (i.e. "I want two new books, this old book archived, and the author's name changed"), by making potentially multiple mutations to the entity graph.
After which, Joist's em.flush
will ensure this "new proposed graph", as an aggregate, in still valid, and then commit all your changes to the database atomically.
Also note that em.flush
enforces "temporary immutability" during its lifecycle, specifically when running validation rules, by "locking" the entities to ensure they are not further mutated while being validated.
(In a way, you can think of Joist's entities as an Immer for your data model--i.e. the database itself progresses through a series of atomic, immutable states (transactions), and Joist's entities are just an ergonomic way to declare what you want the next state to be.)
Aren't ORMs only for programmers who won't learn SQL?
This is a popular assertion, particularly on /r/node
, but Joist considers it FUD, because in stereotypical CRUD apps:
- Over 90-95% of SQL queries* are boilerplate
SELECT
/INSERT
/UPDATE
queries that are tedious/straightforward to write, and - Only 5% of SQL queries are actually complicated and best written in raw, hand-crafted SQL
Given this ratio, Joist's assertion is to let it do the easy/dumb queries for you, not only b/c your code will be more succinct, but also to get the benefits of auto-batching, ergonomic validation rules (there is only so much business logic that can be expressed in SQL constraints), type-safety, etc.
I.e. just because Joist users don't want to write the same INSERT INTO authors (...) VALUES (...)
over and over and over (and then remember, or more likely forget!, to run all the downstream validation rules and update the derived values), does not mean they "don't know SQL". :-)
Granted, if the characteristics of your app change this ratio from 95/5 to 70/30 or 50/50 (perhaps OLAP/analytical applications that focus on complex, read-only reporting requirements), the trade-offs of using Joist, and an ORM in general, will change.
(*We have actually counted the SQL queries in a large, production app, and it was this 95/5 ratio.)
Does Joist make it impossible to write the SQL query I want?
Some engineers have been validly burned by ORMs that force "literally every database query" to go through it's psuedo SQL DSL.
While Joist definitely has a (cute!) SQL DSL, see the em.find
docs, it's very pragmatic about not trying to create every SQL query you could possibly imagine.
Instead, Joist encourages dropping down to raw SQL whenever necessary, albeit hopefully only for SELECT
s, as Joist's business rules are best enforced if all INSERT
and UPDATE
s always go through entities.
What databases does Joist support?
Currently only Postgres; see support other databases.
Why are relations modeled as objects?
In Joist, relations are modeled as wrapper objects, i.e. Author.books
is not a raw array like Book[]
, but instead a Collection<Author, Book[]>
that must have .load()
and .get
called on it.
This can initially feel awkward, but it provides a truly type-safe API, given that relations may-or-may not be loaded from the database, and instead are incrementally into memory.
This is often how business logic wants to interact with the domain model--a continual incremental loading of data as needed, as conditional codepaths are executed, instead of an endpoint/program exhaustively knowing up-front exactly what data will be necessary.
If performance is a concern (loading thousands of entities with many custom properties), Joist provides a ts-patch transform to rewrite the properties as lazy getters in production builds.
Can't I just use Zod for validations in my controller?
Zod works great for crossing the "untyped blob" to "typed POJO" divide, and Joist actually supports Zod for jsonb
columns, which is a similar "untyped jsonb to typed POJO" use case.
However, Zod can only validate fields directly on the "typed input" itself--is this email field a valid email regex, is the required first name field filled in.
This is fine, but Zod can't validate all the other fields in your domain model that now might need revalidated--i.e. maybe the author's age
field changed, so now validate that they're verified, or updating a purchase order line item's amount
cannot make the total order's amount
negative.
Joist's domain model makes it easy to declaratively setup these "cross-field", "cross-entity" business variants, that are more than just z.string().max(20)
, and then ensure they are always enforced, regardless of which controller initiated the mutation.
Joist works particularly well with GraphQL, because GraphQL servers handle the basic "untyped blob -> typed mutation" conversion & checks, similar to what Zod can provide, but they do it "for free" using the GraphQL schema.
Then each mutation can use the already-typed input POJO to update the domain model (typically through upsert-capable methods like em.createOrUpdatePartial
), and then defer all "business variant" validations to the domain model itself.
In our experience, this split of responsibilities is very robust, and leads to small, idiomatic mutation resolvers, much inline with the Rails "fat model, skinny controller" pattern.
Does Joist over-fetch data from the database?
When Joist loads an entity, it does loads of the columns; we've found in practice, for relational databases that load the whole row from disk anyway, this is not a significant performance concern.
That said, all of Joist's "backend reactivity" features, like reactive validation rules & reactive fields, use field-level precision in whether they fire or not. For example, an Author
rule that watches { books: title }
will not trigger when one of it's book changes its book.status
value.
Also, if you have endpoints that require summarizing a lot of children data, Joist's reactive fields are an extremely robust way for keeping materialized columns up-to-date (i.e. tracking Bill.totalPaid
and Bill.totalUnpaid
columns that sum child BillLineItem
rows, for fast, easy sorting & filtering.
Finally, Joist does not have a dogmatic "all queries must be done via the ORM" stance. It's perfectly fine to use Joist's "object graph navigation" and em.find
for 90-95% of your queries (that would be very boilerplate SQL queries), and then use a lower-level query builder for the remaining 10%.
We do have an idea for lazy column support, if you have particularly large columns that should not be fetched by default. We should be able to use Joist's existing "conditionally loaded relations" trick to apply ot "conditionally loaded columns", but have not implemented this yet.
Why must properties be explicitly typed?
When declaring custom properties on entities, currently the fields must be explicitly typed, i.e. the Collection<Author, BookReview>
in the following example is required:
export class Author extends AuthorCodegen {
readonly reviews: Collection<Author, BookReview> = hasManyThrough((author) => author.books.reviews);
}
Obviously as TypeScript fans, we'd love to have these field types inferred, and just do readonly reviews = hasManyThrough
.
Unfortunately, given how interconnected the types of a domain model are, and how sophisticated custom properties can rely on cross-entity typing, attempting to infer the field types quickly leads to the TypeScript compiler failing with cyclic dependency errors, i.e. the Author
's fields can only be inferred if Book
is first typed, but Book
's fields can only be inferred if Author
is first typed.
And adding explicit field types short-circuits these cyclic dependency.
Does Joist require temporal-polyfill
?
No. Joist has optional support for the upcoming JS temporal API; you can opt-in to it by setting temporal: "true"
in joist-config.json
.
If you'd like to keep using Date
, there are no runtime dependencies on temporal-polyfill
, but if you get errors like:
node_modules/joist-orm/build/utils.d.ts:1:56 - error TS2307: Cannot find module 'temporal-polyfill' or its corresponding type declarations.
1 import type { Intl, Temporal, toTemporalInstant } from "temporal-polyfill";
Then you either need to enable skipLibCheck: "true"
in your tsconfig.json
(recommended, as this disables unnecessary type-checking of your dependency's *.ts
code), or install temporal-polyfill
as a devDependency
.
Can I customize the formatter?
Joist uses ts-poet and dprint-node to generate & format code, as dprint is significantly faster than Prettier when generating large amounts of code.
The ts-poet output attempts to be "prettier-ish", but if you'd like to customize it, you can create a .dprint.json
file as per the dprint docs.