Skip to content

TypeScript correctness: target state and tracker (September 2026) #778

Description

@InfinityBowman

Tracking issue for the September 2026 TypeScript correctness work. Read this before doing type work in this repo -- the rules below are the target state, and every linked issue is an instance of one of them.

Successor to packages/docs/audits/typescript-audit-2026-04.md, which is still accurate on the items it raised. Status against it is in #777.


The target state

1. One source of truth per shape, declared where the data is produced

A shape is declared once, where it comes into existence, and derived everywhere downstream. Never restate a shape in a second file.

Restating is how PR #767/#768's bugs happened: hand-written interfaces described the admin API, the server stopped producing three of the fields, and nothing failed -- because the components cast the query result to the interface, so the interface was only ever checked against itself. Three bugs hid behind that (permanently-undefined display names, expiresAt * 1000 on a Date, a 'limited' branch testing a union that never contains it).

The fix PR #768 used is the pattern to copy:

// admin-projects.server.ts -- declared once, at the producer
export type AdminProjectDetails = Awaited<ReturnType<typeof getAdminProjectDetails>>;
export type AdminProjectMember = AdminProjectDetails['members'][number];

Caveat that travels with it: Awaited<ReturnType<...>> makes the implementation the contract. That is the right trade inside a package where consumers are few and local. It is the wrong trade across one -- packages/shared and packages/db both set declaration: true, so an inferred return type is emitted into a .d.ts and becomes a published contract nobody wrote. Exported functions in shared and db get explicit return types. Everything else derives.

2. Annotate at boundaries, infer in the interior

An annotation and an assertion are opposite operations. const x: Foo = expr asks the compiler to check. expr as Foo tells it to stop checking.

"Fewer types" is not the goal and never was. Fewer sources of truth is one goal; fewer unchecked claims is the other. Several issues in this list deliberately add type code.

Annotate: function parameters, exported signatures in shared/db, union definitions, empty-collection initializers. Infer: locals, obvious initializers, everything downstream of a boundary. Reach for satisfies when you want checking without widening -- 8 uses repo-wide is a sign it is being forgotten.

3. A cast is a claim, and it must be earned

Three questions, in order:

  1. Can the type come from the producer instead? Then it is rule 1, not a cast.
  2. Is this an external boundary? Then it is a parse, not a cast.
  3. Is this a third-party API the type system genuinely cannot see?

Only the third is a legitimate as, and it belongs in an adapter module, not scattered across call sites. Replacing as Foo with an unverified type guard is not progress -- it relocates the assertion.

And every surviving any or as at a generic boundary carries a comment saying why it is necessary. This is the standard cf-sync-engine already holds itself to, and it is checkable in review in a way that "isolate it in an adapter" is not. Real examples from that repo:

// `any` keeps concrete registries assignable in constraint position across generics.
export type AnyMutators = Record<string, MutatorDef<any, any, any>>

// Deliberately `any`-based: concrete schemas must remain assignable in both
// directions across generic boundaries (method bivariance handles MutatorTx),
// which `SyncSchema` with concrete table types would block.
export type AnySyncSchema = SyncSchema<Record<string, any>>

A cast that cannot be given such a comment has not been earned.

4. Optional means "the caller may omit this," not "I am not sure"

If the honest reason for a ? is that you do not know whether the field is there, the field is unvalidated and wants a parse. An all-optional type cannot fail, which is the same as having no type. Options bags and props-with-defaults are the legitimate exception.

5. Nullable is | null. Absent is ?. They are not the same thing

Drizzle left joins produce T | null, never T | undefined. userAvatar?: string for a left-joined column claims the field may be missing when the truth is it is always present and may be null. With exactOptionalPropertyTypes off, ?: string additionally admits an explicit undefined -- three states impersonating two.


The boundaries in this codebase

An agent should be able to point at any cast and say which row it sits on.

Boundary Rule
Server-function inputs Zod validator using the branded schemas from @corates/shared/ids. No cast in the handler.
Server-function outputs Declared once at the producer, derived downstream. Never restated.
Yjs / CRDT reads Untrusted -- written by peer clients. Parse in a facade, never cast at the call site.
Third-party SDKs (Better Auth, Stripe, Drizzle internals, Yjs) The only sanctioned as. Isolate in an adapter module.
catch unknown, narrowed through a shared helper.
Cross-package exports (shared, db) Explicit return types. The .d.ts is a published contract.

Deliberately not goals

  • Zero casts. Branded-ID minting (crypto.randomUUID() as ProjectId) and third-party SDK gaps are legitimate.
  • Fewer type declarations. See rule 2.
  • Better generics for Yjs. Y.Map values are dynamically typed at the protocol level. The facade is the answer.
  • Migrating every route to createServerFn at once. New endpoints yes; existing ones on their own schedule.

The in-house reference

cf-sync-engine (../cf-sync-engine) is the working example of this target state, in a repo we own. Measured over its own source, excluding the vendored packages/yjs/reference/ tree: 40 files, 6,549 lines.

cf-sync-engine CoRATES
noUncheckedIndexedAccess on, from the first commit off everywhere
Shared tsconfig.base.json yes no, four configs drifting
as any 0 18 hand-written (73 more in generated routeTree.gen.ts)
as Error 0 77 in non-test source
satisfies per 10k lines ~11 ~0.8
Type tests 23 assertions, 3 files 0
Optional-field density 20% 32%

Cast density is nearly identical (1 per 94 lines there, 1 per 92 here) -- the difference is entirely in kind. What to copy specifically:

  • Every any is justified in a comment, with a real constraint behind it (assignability at generic boundaries, method bivariance), not an excuse.
  • declaration: true awareness. AuthContextCarrier is a type alias rather than an interface because declaration emit would force consumers into TS4023, and because interfaces get no implicit index signature. That is exactly the knowledge packages/shared and packages/db need for rule 1's caveat.
  • unknown chosen over any at inference sites. defineMutators constrains A extends Record<string, unknown> so a mutator with no args schema falls back to unknown the body must narrow, rather than a silent any.
  • Input and output shapes kept distinct. RowOf (defaults applied) versus RowInputOf (defaults omissible) -- the z.input / z.output split most codebases collapse.
  • Validation that actually validates. TABLE_NAME_RE, MAX_ID_LENGTH, rejection on empty and NUL, each throwing with a useful message.

Where it is not a model: crudMutators ends on an as unknown as double cast; buildMutate(): any leaves the proxy call tree untyped; and StandardSchemaV1<any, ...> puts any in input position throughout. Its @cf-sync/yjs package does not solve #773 -- it keeps Y.Doc contents opaque and syncs byte-level frames, so it never reads structure out of a document the way CoRATES does.


Issues, in suggested order

Order Issue Effort Why this position
1 #769 requireQuota fails open 30 min Only latent defect found; fails open on a billing limit
2 #775 router-param casts 1 hr Mechanical deletion; cause is a paired to as string cast, verified by typecheck
3 #770 branded schemas in validators 1-2 hr Free, and makes ids.ts true
4 #771 errorMessage helper 2-3 hr Mechanical; the single most common bad cast
5 #779 shared tsconfig.base.json 2-3 hr Prerequisite for #774; workers is the least-checked package today
6 #774 noUncheckedIndexedAccess half day+ Compiler enforcement of rule 4
7 #772 ErrorDetails union (reduced scope) 2-3 hr Real codes and no index signatures; the discriminated union is deferred until a consumer narrows details
8 #776 optional-field rule few hr Incremental
9 #777 type tests and April carry-overs 1-2 hr Stops the rest from regressing
10 #773 Yjs facade 1-2 days Highest value; the fallback decision is mostly made already, see the issue

#773 is last by sequence, not importance. It is the only item touching a genuinely untrusted boundary, and it needs a product decision about invalid production documents before code gets written.


Baseline, September 2026

'as <Type>' casts (non-test source, 722 files / 100k LOC)   1,086   (1,070 in packages/web)
'as unknown as' double casts                                   54
'as Error' (non-test source; 101 including tests)              77
'as any' (hand-written; 73 more in generated routeTree.gen.ts)  18
'as Record<string, unknown>'                                   96   (31 files)
'as Record<string, string>' on router params                   31   (each paired with a 'to as string' cast, #775)
satisfies                                                       8
Type tests                                                      0
Value imports of '@corates/shared/ids'                          0
Optional fields (declarations with 3+ fields)            1,056 / 3,307  (32%)
Declarations >=60% optional                                132 / 486    (27%)

Good baseline to build on: strict: true in all four packages, zero @ts-ignore, zero @ts-expect-error, assertNever already exists in packages/shared/src/assert-never.ts, Zod at 25 boundary files.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions