Column names (snake_case in the database, camelCase in the code)¶
Keep the SQL convention in the database without letting it leak into the TypeScript domain.
The problem¶
snake_case is the universal convention in SQL. camelCase is the universal
convention in TypeScript. If the column name is always the property name, you are
forced to choose between two bad things:
- Properties in
snake_case— it works, butInferModelnow renders{ consumer_name: string, rate_limit_burst: number }, and that type travels through service, controller and response schema. The database convention leaks all the way to the HTTP edge. - Renaming the database columns — it breaks the SQL convention, forces quotes in every hand-written query, and on a production database it is a rename migration on a hot table.
SQLAlchemy solves it with mapped_column("consumer_name"), Django with
db_column, Prisma with @map. Here there are two ways.
Per column — .name()¶
import { Model, column } from "tempest-db-js";
class ApiKey extends Model {
static tablename = "api_keys";
id = column.integer().primaryKey();
consumerName = column.text().name("consumer_name").notNull(); // (1)!
rateLimitBurst = column.integer().name("rate_limit_burst").notNull();
}
- The property stays
consumerNamein TypeScript; the column isconsumer_namein the database.
Per table — static naming¶
When the whole schema follows one convention, annotating column by column is noise:
class ApiKey extends Model {
static tablename = "api_keys";
static naming = "snake_case"; // (1)!
id = column.integer().primaryKey();
consumerName = column.text().notNull(); // -> consumer_name
rateLimitBurst = column.integer().notNull(); // -> rate_limit_burst
}
- The values are
"preserve"(the default — the property name, verbatim) and"snake_case".
.name() still applies and wins over the table strategy, for the exception
every real schema has:
class ApiKey extends Model {
static tablename = "api_keys";
static naming = "snake_case";
consumerName = column.text().notNull(); // -> consumer_name
legacyId = column.text().name("legacyID").notNull(); // -> legacyID
}
The mapping applies everywhere¶
It is not a SELECT detail. The translated name appears in every clause that
reaches SQL, and the property name in everything that comes back to TypeScript:
select(ApiKey, ["consumerName"])
.where({ consumerName: { ieq: "acme" } })
.orderBy("rateLimitBurst", "desc");
// SELECT "consumer_name" FROM "api_keys"
// WHERE lower("consumer_name") = lower($1) ORDER BY "rate_limit_burst" DESC
const row = await session.execute(select(ApiKey).where({ consumerName: "acme" })).one();
row.consumerName; // ✅ the property, not "consumer_name"
Full coverage: select (projection, where, orderBy, groupBy, aggregates),
insert (columns, ON CONFLICT target and predicate, returning), update
(set and where), del, joins ("alias"."column" qualification),
BaseRepository, active-record, and the migration IR.
No false drift
The migration IR is produced in column-name space, the same space
introspection reads from the database. That is why checkDriftPostgres stays
clean — if the mapping only applied to queries, every renamed column would
show up as "missing from the database".
A collision is an error, not last-write-wins
Two properties resolving to the same column raise on the model's first reflection:
Cost¶
A model that renames nothing has a null map, and the compiler never even looks
it up — the hot path is identical to before. The map is memoized per class, like
the rest of model reflection.
Recap¶
.name("column")renames one column;static naming = "snake_case"renames the whole table..name()overrides the table strategy.- The mapping covers queries, mutations, joins, the repository and the migration IR — with no false drift.
- The row you get back is always in property-name space.
- A name collision fails loudly.