A TypeScript service has an internal helper `query<T>(sql: string): Promise<T[]>` called from hundreds of places, each passing its own row type, and several of those row types no longer match the database. Why will the compiler never report this, and how would you get the codebase back to safety?
answer
- no inference site, so no check
- every call site is an `as` in disguise
- make the compiler enumerate the sites
- migrate beside, not in place
- generate types from the schema
basics
~20 sT appears only in the return type, so each call site asserts rather than proves its row type and the checker has nothing to compare it against. The fix is a signature that forces the type to be produced — returning unknown or requiring a decoder — so every unsafe site becomes a compile error.
solid answer
~50 sThe compiler is silent because that helper has no inference site: the only argument is a SQL string, so whatever a caller writes in the angle brackets is accepted as fact. Every call site is really an `as Row[]` in disguise, and an assertion is checked against nothing. So no audit tool inside the type system can find the drift — I would start by listing the call sites that pass an explicit type argument and cross-checking the highest-traffic ones against the actual schema, plus temporary runtime assertions in a canary to see what really comes back. Then I recruit the compiler: add a `query` variant that returns `Promise<unknown[]>` or requires a row decoder, migrate module by module so the build never goes fully red, and delete the old helper once nothing calls it. Long term, row types should be generated from the schema so drift breaks the build rather than production.
go deeper
Recall that a type argument you type at a call site is a claim, not a check, and that data coming from a database is unverified until something inspects it at runtime.
Explain the mechanism precisely: no argument mentions T, so there is no inference site, and the compiler accepts the caller's type argument. Show the signature change that makes bad call sites fail to compile.
Demonstrate that you can stage the repair — audit against the real schema, add the safe helper alongside, migrate in reviewable batches, delete the old one — and that you know the checker cannot find these bugs for you.
Own the systemic answer: move the source of truth to generated types, define one boundary where external data is decoded, and set the review rule that a return-only type parameter counts as an assertion anywhere in the codebase.
## Why no error exists `query<T>(sql: string): Promise<T[]>` mentions T exactly once, in the return type. Type parameters are solved by matching arguments against parameter types; a `string` parameter offers no candidate for T, so the caller's explicit type argument is taken on trust. `query<UserRow>("select ...")` and `(await rawQuery(sql)) as UserRow[]` are the same operation with different ergonomics. Two further facts complete the picture. First, types are erased, so the helper cannot inspect rows at runtime even in principle — there is no `T` value to check against. Second, the database driver's own result type is effectively untyped (rows arrive as plain objects), so nothing upstream contradicts the claim either. The type system is modelling data it has never seen, and every call site is an independent, unreviewed promise about that data. The practical consequence is the one to state plainly in an interview: **this class of bug is undetectable by the type checker by construction.** Adding stricter compiler flags will not surface it. A renamed column produces `undefined` at a property access, a widened nullable column produces a runtime `null` where the type said `string`, and a removed column produces code that compiles perfectly and returns nothing. ## Confirming the damage Because static analysis of the types cannot help, the evidence has to come from outside the type system: - **Enumerate the claims.** Search for call sites that pass an explicit type argument to `query`. That list is the complete set of unverified assertions; it is finite and usually smaller than feared. - **Compare against the real schema.** Introspect the live schema (column names, nullability) and diff it against the row interfaces. This is a one-off script, but it is the only mechanical check available before the refactor. - **Observe production.** A temporary decode-and-log step on the hottest queries — validate the first N rows, log mismatches, do not throw — tells you which drifts are actually live rather than theoretical, and lets you order the work by risk. - **Prioritise by blast radius.** Nullability lies and missing columns are worse than an extra column nobody reads. ## Recruiting the compiler The repair is to change the signature so the type must be **produced**, not requested. Two shapes work: ```ts // 1. Return unknown and force each caller to narrow. declare function query(sql: string): Promise<unknown[]>; // 2. Require a decoder, so T is inferred from code that runs. declare function queryRows<T>( sql: string, decodeRow: (raw: unknown) => T, ): Promise<T[]>; ``` Option 2 is usually the better destination for a data-access layer: the type parameter now appears twice, the static type and the runtime check come from one source, and callers keep the ergonomics they had. Either way, the crucial property is that **changing the signature turns every unsafe call site into a compile error**, converting an invisible problem into a work list the build maintains for you. ## Sequencing it without a red build A single commit that widens the old return type breaks hundreds of files at once, which in practice gets reverted rather than fixed. The migration that lands: 1. Add the new helper alongside the old one; do not touch existing calls. 2. Point all new code at it — enforced in review, or with a lint rule banning the old symbol in new files. 3. Migrate module by module, starting with the queries the audit flagged. Each batch is small, reviewable, and independently shippable. 4. When the old helper has no callers, delete it. Leaving it as deprecated preserves the exact hazard you spent the effort removing. If the codebase is large enough that hand-editing is impractical, a codemod can insert the decoder argument mechanically, leaving humans to write the decoders — which is the part that requires judgment anyway. ## Preventing the recurrence - **Generate row types from the schema.** When the interfaces are derived from migrations rather than hand-written, a schema change breaks the build at generation time. This is the structural fix; everything else is discipline. - **Validate once, at the boundary.** One decoding layer where rows enter the application beats assertions scattered through the app, and it gives you a single place to add logging or tolerance for optional fields. - **Make the review rule explicit.** A type parameter that appears only in a return type is a cast; treat it as one in review, wherever it shows up — query helpers, cache reads, config lookups, message payloads. - **Watch for the same shape elsewhere.** Once you have found one `get<T>(key)` in the codebase you will usually find four more. ## The judgment being tested The question is less about knowing the antipattern and more about knowing that the type checker cannot help you find it, that the repair must be staged, and that the durable fix moves the source of truth to the schema. A candidate who answers only "use unknown" has the mechanism but not the migration; a candidate who proposes rewriting every call site in one commit has not shipped this kind of change before.
- Would enabling stricter compiler options such as noUncheckedIndexedAccess have caught any of this?No. Strictness flags tighten how the compiler reasons about types it already has; they cannot question a type the caller asserted. noUncheckedIndexedAccess would add undefined to index accesses on the row array, which is useful in general, but the row's declared shape would still be whatever the call site claimed.
- Is there any version of this helper where an explicit type argument is acceptable?Yes, when the type argument is not hand-written — for example row types generated from the schema and filled in by generated query code. The guarantee then comes from regeneration, not from the type system. The hazard is specifically a human choosing the type at a call site with nothing checking the choice.
- How would you stop the old helper coming back after you delete it?A lint rule banning the symbol, plus the generated types making the safe path the easy one. Mostly, though, deletion is the enforcement: if the unsafe function does not exist, nobody can call it. Leaving it deprecated is what lets it survive for years.
- What do you do when a decoder is too expensive for a hot query returning millions of rows?Decode a sample rather than every row, or validate the shape once per result set instead of per row, and keep the strict path everywhere else. Make the fast path explicit and localized — a named function with a comment explaining the tradeoff — so it stays a deliberate exception rather than the default.
saying these in an interview costs you the question
- Turning on strict mode would have caught the mismatched rows
- The generic checks the row shape when the query runs
- Just widen the helper to unknown in one commit and fix the errors
- Deprecating the old helper is enough, no need to delete it
- Unit tests with mocked rows prove the types match the database