pg types result.rows as any[]. Every row this application reads therefore enters a strict: true codebase as any, and everything derived from it is unchecked from that point on — rows[0].price_cents, a field renamed in a migration, a column dropped from a SELECT. The compiler has nothing to say about any of it.
Measured on main:
pool.query<T>(...) with a row type
1
pool.query(...) total
89
client.query(...) total
96
rows[0] reads
36
Row interfaces in the codebase
1 (CustomerRow)
Roughly 184 query sites, one of them typed. Concentrated in a handful of files:
The TypeScript hygiene here is otherwise good — strict: true on both workspaces, no enum, two defensible non-null assertions, no @ts-ignore anywhere. This is the one place the type system is switched off, and it is the boundary where the data actually comes from.
#101 wants noUncheckedIndexedAccess, which turns rows[0] into T | undefined and demands the check. Enabled today it would bite on almost nothing, because rows[0] is already any and any indexes to any. The violation count would be small, would suggest the flag was cheap, and would change completely once the rows have real types.
That issue currently says it waits on "#65 stage 3". #65 is closed and turned out to be Import convention and avoidable any, which had no stages and never included a typing programme — it delivered CustomerRow and the PoolClient annotations in cartCheckout.ts and nothing more. So #101's dependency is real but points at nothing. This is that dependency.
Where the types belong
itemSelect.ts is the precedent worth following. It already centralises the item SELECT text precisely because two callers need the same shape and a third one-to-many relation would otherwise multiply rows — but nothing types what those SELECTs return. The row type belongs next to the SELECT that produces it, so the two cannot drift: a column added to PUBLIC_ITEM_SELECT and not to its type is then a compile error rather than a field that is silently any.
That argues against one big types.ts of table-shaped interfaces. A table interface describes the table; what a route needs is the shape of its projection, and those differ deliberately — PUBLIC_ITEM_SELECT omits paypal_order_id and reserved_until because the storefront has no business seeing them. Typing rows as the full table would quietly re-admit exactly the columns that select was written to exclude.
Scope
Row types for the projections actually selected, defined beside their SELECT
Applied to pool.query and client.query alike. The client.query calls are the transaction paths in cartCheckout.ts, where a mistake is most expensive and where any currently hides a client/pool mix-up
CustomerRow already exists and moves to wherever the convention lands
Landing in stages by file rather than as one 184-site change, since the point is to be reviewable
Deliberately not in scope
Generating types from the schema.pg-to-ts, Prisma introspection and friends solve this properly and bring a build step, a dependency, and a generated artifact to keep in step with node-pg-migrate. That is a bigger commitment than this repository has taken elsewhere, and it should be a decision rather than a side effect of wanting rows[0] checked. Recorded here so it is not re-litigated in review.
Turning on noUncheckedIndexedAccess. That is #101, and it comes after.
Acceptance criteria
Every .query(...) call in backend/src carries a row type, or is explicitly exempted with a reason
Row types live beside the SELECT they describe rather than in one table-shaped module
A column removed from a shared SELECT and not from its type fails the build, demonstrated rather than assumed
No new dependency and no generated code
#101's dependency is re-pointed at this issue and the stale #65 reference removed
`pg` types `result.rows` as `any[]`. Every row this application reads therefore enters a `strict: true` codebase as `any`, and everything derived from it is unchecked from that point on — `rows[0].price_cents`, a field renamed in a migration, a column dropped from a SELECT. The compiler has nothing to say about any of it.
Measured on `main`:
| | |
| --- | --- |
| `pool.query<T>(...)` with a row type | **1** |
| `pool.query(...)` total | 89 |
| `client.query(...)` total | 96 |
| `rows[0]` reads | 36 |
| Row interfaces in the codebase | **1** (`CustomerRow`) |
Roughly 184 query sites, one of them typed. Concentrated in a handful of files:
```
43 routes/customers.ts
29 routes/cartCheckout.ts
25 routes/admin.ts
19 routes/cart.ts
19 routes/adminCustomers.ts
18 routes/shippingAddresses.ts
```
The TypeScript hygiene here is otherwise good — `strict: true` on both workspaces, no `enum`, two defensible non-null assertions, no `@ts-ignore` anywhere. This is the one place the type system is switched off, and it is the boundary where the data actually comes from.
## Why this blocks #101
#101 wants `noUncheckedIndexedAccess`, which turns `rows[0]` into `T | undefined` and demands the check. Enabled today it would bite on almost nothing, because `rows[0]` is already `any` and `any` indexes to `any`. The violation count would be small, would suggest the flag was cheap, and would change completely once the rows have real types.
That issue currently says it waits on "#65 stage 3". #65 is closed and turned out to be *Import convention and avoidable `any`*, which had no stages and never included a typing programme — it delivered `CustomerRow` and the `PoolClient` annotations in `cartCheckout.ts` and nothing more. So #101's dependency is real but points at nothing. This is that dependency.
## Where the types belong
`itemSelect.ts` is the precedent worth following. It already centralises the item SELECT text precisely because two callers need the same shape and a third one-to-many relation would otherwise multiply rows — but nothing types what those SELECTs return. **The row type belongs next to the SELECT that produces it**, so the two cannot drift: a column added to `PUBLIC_ITEM_SELECT` and not to its type is then a compile error rather than a field that is silently `any`.
That argues against one big `types.ts` of table-shaped interfaces. A table interface describes the table; what a route needs is the shape of *its* projection, and those differ deliberately — `PUBLIC_ITEM_SELECT` omits `paypal_order_id` and `reserved_until` because the storefront has no business seeing them. Typing rows as the full table would quietly re-admit exactly the columns that select was written to exclude.
## Scope
- Row types for the projections actually selected, defined beside their SELECT
- Applied to `pool.query` and `client.query` alike. The `client.query` calls are the transaction paths in `cartCheckout.ts`, where a mistake is most expensive and where `any` currently hides a client/pool mix-up
- `CustomerRow` already exists and moves to wherever the convention lands
- Landing in stages by file rather than as one 184-site change, since the point is to be reviewable
## Deliberately not in scope
**Generating types from the schema.** `pg-to-ts`, Prisma introspection and friends solve this properly and bring a build step, a dependency, and a generated artifact to keep in step with `node-pg-migrate`. That is a bigger commitment than this repository has taken elsewhere, and it should be a decision rather than a side effect of wanting `rows[0]` checked. Recorded here so it is not re-litigated in review.
**Turning on `noUncheckedIndexedAccess`.** That is #101, and it comes after.
## Acceptance criteria
- Every `.query(...)` call in `backend/src` carries a row type, or is explicitly exempted with a reason
- Row types live beside the SELECT they describe rather than in one table-shaped module
- A column removed from a shared SELECT and not from its type fails the build, demonstrated rather than assumed
- No new dependency and no generated code
- #101's dependency is re-pointed at this issue and the stale #65 reference removed
Correction to the acceptance criteria before any work starts: one of them cannot be met by the approach this issue chooses, and it was wrong to write it.
A column removed from a shared SELECT and not from its type fails the build, demonstrated rather than assumed
It will not. pool.query<T>(...) is an assertion about the shape, not a check against the SQL. TypeScript never reads the query string, so a column dropped from PUBLIC_ITEM_SELECT while ItemRow still declares it compiles cleanly, and every read of that field keeps type-checking while being undefined at runtime. The type would simply be a lie the compiler now believes.
Catching that genuinely requires the schema-aware tooling this issue rules out — generation from the database, or a query builder that owns both halves. Wanting the criterion does not make the chosen approach able to deliver it.
What typing the rows does buy, which is still worth doing and is what #101 needs:
A misspelled field at a read site becomes a compile error instead of undefined
A rename applied to the type fails at every read site that still uses the old name, so the change is complete rather than mostly complete
rows[0] stops being any, which is the precondition that makes noUncheckedIndexedAccess measure something real
The shape a route actually returns becomes readable without reconstructing it from the SQL
What it does not buy, and should be written into the code rather than assumed by the next reader: the type and the SELECT are kept in step by hand. That is a weaker guarantee than it looks, and the comment beside each shared SELECT should say so, otherwise the type reads as verified when it is asserted.
So that criterion is replaced by:
Each shared SELECT's row type sits beside it with a note that the two are hand-kept, since nothing checks the SQL against the type
The integration suite is what actually catches a SELECT and its type disagreeing, because it runs the real queries against a real schema — worth stating, as it is the only thing that does
Correction to the acceptance criteria before any work starts: one of them cannot be met by the approach this issue chooses, and it was wrong to write it.
> A column removed from a shared SELECT and not from its type fails the build, demonstrated rather than assumed
It will not. `pool.query<T>(...)` is an assertion about the shape, not a check against the SQL. TypeScript never reads the query string, so a column dropped from `PUBLIC_ITEM_SELECT` while `ItemRow` still declares it compiles cleanly, and every read of that field keeps type-checking while being `undefined` at runtime. The type would simply be a lie the compiler now believes.
Catching that genuinely requires the schema-aware tooling this issue rules out — generation from the database, or a query builder that owns both halves. Wanting the criterion does not make the chosen approach able to deliver it.
**What typing the rows does buy**, which is still worth doing and is what #101 needs:
- A misspelled field at a read site becomes a compile error instead of `undefined`
- A rename applied to the type fails at every read site that still uses the old name, so the change is complete rather than mostly complete
- `rows[0]` stops being `any`, which is the precondition that makes `noUncheckedIndexedAccess` measure something real
- The shape a route actually returns becomes readable without reconstructing it from the SQL
**What it does not buy**, and should be written into the code rather than assumed by the next reader: the type and the SELECT are kept in step by hand. That is a weaker guarantee than it looks, and the comment beside each shared SELECT should say so, otherwise the type reads as verified when it is asserted.
So that criterion is replaced by:
- Each shared SELECT's row type sits beside it with a note that the two are hand-kept, since nothing checks the SQL against the type
- The integration suite is what actually catches a SELECT and its type disagreeing, because it runs the real queries against a real schema — worth stating, as it is the only thing that does
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
pgtypesresult.rowsasany[]. Every row this application reads therefore enters astrict: truecodebase asany, and everything derived from it is unchecked from that point on —rows[0].price_cents, a field renamed in a migration, a column dropped from a SELECT. The compiler has nothing to say about any of it.Measured on
main:pool.query<T>(...)with a row typepool.query(...)totalclient.query(...)totalrows[0]readsCustomerRow)Roughly 184 query sites, one of them typed. Concentrated in a handful of files:
The TypeScript hygiene here is otherwise good —
strict: trueon both workspaces, noenum, two defensible non-null assertions, no@ts-ignoreanywhere. This is the one place the type system is switched off, and it is the boundary where the data actually comes from.Why this blocks #101
#101 wants
noUncheckedIndexedAccess, which turnsrows[0]intoT | undefinedand demands the check. Enabled today it would bite on almost nothing, becauserows[0]is alreadyanyandanyindexes toany. The violation count would be small, would suggest the flag was cheap, and would change completely once the rows have real types.That issue currently says it waits on "#65 stage 3". #65 is closed and turned out to be Import convention and avoidable
any, which had no stages and never included a typing programme — it deliveredCustomerRowand thePoolClientannotations incartCheckout.tsand nothing more. So #101's dependency is real but points at nothing. This is that dependency.Where the types belong
itemSelect.tsis the precedent worth following. It already centralises the item SELECT text precisely because two callers need the same shape and a third one-to-many relation would otherwise multiply rows — but nothing types what those SELECTs return. The row type belongs next to the SELECT that produces it, so the two cannot drift: a column added toPUBLIC_ITEM_SELECTand not to its type is then a compile error rather than a field that is silentlyany.That argues against one big
types.tsof table-shaped interfaces. A table interface describes the table; what a route needs is the shape of its projection, and those differ deliberately —PUBLIC_ITEM_SELECTomitspaypal_order_idandreserved_untilbecause the storefront has no business seeing them. Typing rows as the full table would quietly re-admit exactly the columns that select was written to exclude.Scope
pool.queryandclient.queryalike. Theclient.querycalls are the transaction paths incartCheckout.ts, where a mistake is most expensive and whereanycurrently hides a client/pool mix-upCustomerRowalready exists and moves to wherever the convention landsDeliberately not in scope
Generating types from the schema.
pg-to-ts, Prisma introspection and friends solve this properly and bring a build step, a dependency, and a generated artifact to keep in step withnode-pg-migrate. That is a bigger commitment than this repository has taken elsewhere, and it should be a decision rather than a side effect of wantingrows[0]checked. Recorded here so it is not re-litigated in review.Turning on
noUncheckedIndexedAccess. That is #101, and it comes after.Acceptance criteria
.query(...)call inbackend/srccarries a row type, or is explicitly exempted with a reasonCorrection to the acceptance criteria before any work starts: one of them cannot be met by the approach this issue chooses, and it was wrong to write it.
It will not.
pool.query<T>(...)is an assertion about the shape, not a check against the SQL. TypeScript never reads the query string, so a column dropped fromPUBLIC_ITEM_SELECTwhileItemRowstill declares it compiles cleanly, and every read of that field keeps type-checking while beingundefinedat runtime. The type would simply be a lie the compiler now believes.Catching that genuinely requires the schema-aware tooling this issue rules out — generation from the database, or a query builder that owns both halves. Wanting the criterion does not make the chosen approach able to deliver it.
What typing the rows does buy, which is still worth doing and is what #101 needs:
undefinedrows[0]stops beingany, which is the precondition that makesnoUncheckedIndexedAccessmeasure something realWhat it does not buy, and should be written into the code rather than assumed by the next reader: the type and the SELECT are kept in step by hand. That is a weaker guarantee than it looks, and the comment beside each shared SELECT should say so, otherwise the type reads as verified when it is asserted.
So that criterion is replaced by:
feature/159-type-query-resultsto feature/159-type-checkout-queries