pinpoint-typescript
TypeScript strictest patterns, type guards, optional properties (exactOptionalPropertyTypes), Drizzle query safety, null checks. Use when fixing type errors, implementing complex types, or when user m
SKILL.md.claude/skills/pinpoint-typescript
GitHubPinPoint TypeScript
What the toolchain actually enforces
pnpm run typecheck runs tsc --noEmit -p tsconfig.app.json, which extends
@tsconfig/strictest — exactOptionalPropertyTypes and
noUncheckedIndexedAccess both on. e2e/tsconfig.json extends it too. Test
files do not: tsconfig.tests.json extends only tsconfig.base.json with
strict: true, so both flags are off across everything it owns —
src/**/*.test.ts(x), src/**/*.spec.ts(x), src/test/**, and vitest.config.ts.
The CORE-TS-* rules the compiler cannot express are carried by Oxlint, and only
in app code. no-explicit-any (CORE-TS-007) and explicit-function-return-type
(CORE-TS-006) are both switched off for e2e/**, for test files, and for
*.config.* / scripts/**. Writing const page: any in an e2e spec passes
every gate.
CORE-TS-007's ban on ! is linted, as of PP-8k07:
typescript/no-non-null-assertion is "error", and
pinpoint/no-restricted-disable-directives names it, so you cannot disable-comment
past it in src/. It is off in the test and e2e blocks — a wrong assertion
there fails the test loudly instead of 500ing a page — and that test exemption
is meant to go away (PP-9q5k), so don't read it as blessed. Note the scope
differs from the paragraph above: scripts/** and *.config.* keep
no-non-null-assertion at "error", but they switch
pinpoint/no-restricted-disable-directives off, so a // oxlint-disable-next-line
(or // eslint-disable-next-line) does work there.
The third of CORE-TS-007 that no tool checks is unsafe as. The
no-unsafe-* rules target untyped values, not casts between two known types,
and tsc accepts the cast by construction. That one rests entirely on review.
Full catalog: CORE-TS-001..008 in docs/NON_NEGOTIABLES.md.
exactOptionalPropertyTypes: omit the key, don't assign undefined
The compiler flags this but does not teach the fix. Of the two cheapest
silencers, an as cast is a CORE-TS-007 violation; widening the property to
| undefined is not, but it changes the contract — the key must then always be
present. Usually what you want is an absent key, which a conditional spread
produces:
const data = {
id: uuid(),
...(name !== undefined && { name }),
};
Guard on !== undefined, not truthiness. ...(name && { name }) also drops ""
and 0, which turns "the user cleared this field" into "the user never touched
it."
There is no db→app converter layer, and you should not build one
Row types are declared in src/lib/types/database.ts and imported from the
~/lib/types barrel (CORE-TS-001). The barrel does not yet re-export all of
them — Notification, NotificationPreference, IssueWatcher, IssueImage
and the PinballMap types are missing, and a few callers import around it as a
result. If the type you need isn't re-exported, add it to the barrel rather
than deepening that path, and never redeclare a parallel hand-written shape.
Most are plain InferSelectModel; Issue is the exception, an Omit<> that
narrows four text columns to string unions.
A dbUserToUser / toProfileSummary style converter would be pure ceremony:
there is no second type system for it to translate into, and every such function
is a place for the two shapes to drift.
This is not hypothetical. PinPoint ran exactly that layer until #480: a
type-level DrizzleToCamelCase<T> wrapping essentially every response type, and
a runtime transformKeysToCamelCase invoked 76 times across 23 files — the DAL,
every tRPC router, and a family of dedicated *-response-transformers.ts
modules. Because a runtime transform cannot carry types through, it forced an
as assertion at nearly every one of those sites, which is the one currency
ts-strictest exists to refuse. It was only necessary because the v1 schema
omitted column names; the v2 schema declares both (firstName: text("first_name")),
and that single authoring choice is what made the layer unnecessary. Do not
reintroduce it, and never reach for a Drizzle row by its column name
(row.full_name) — that property does not exist. (CORE-TS-003.)
What is legitimate is narrowing a row before it crosses a boundary — a
Client Component prop type or an API response should be a minimal Pick<> /
purpose-built shape, not the whole ORM row. That's a projection, not a
conversion. (CORE-SEC-006, and CORE-SEC-007 for email specifically.)
Conversion is correct wherever Drizzle's mapping does not run — it is the supabase-js and raw-SQL paths that need it, not Drizzle rows:
db.execute()and raw SQL (results skip Drizzle'smapResultRowentirely)- supabase-js reads and writes, both
.rpc()and.from().select()— seegetDiscordConfigandisDiscordIntegrationEnabledinsrc/lib/discord/config.ts - Supabase auth metadata — the
user_metadataextraction insrc/lib/auth/profile.ts - External APIs (
src/lib/pinballmap/parse.ts)