2 · TypeScript¶
TypeScript is the safety net a static SPA has instead of a server. There is no backend to re-validate a shape, no runtime to catch a bad cast before the user sees a blank screen — the compiler is where mistakes get caught. These rules lock the config to strict, keep the type-check fast and correct across packages, and forbid the escape hatches that quietly turn a typed codebase back into an untyped one.
Rules¶
R-TS-1 · Strict mode is on¶
Rule. tsconfig (the base that app sources extend) sets "strict": true.
Why. strict turns on the flags that catch the bugs a static app can't recover from at
runtime — strictNullChecks above all. Without it, undefined flows silently into render
and the user gets a white screen, not an error you can see. One flag, most of the value.
vcqa. The effective config for src/** has strict: true; flag strict: false or any
individual strict-family flag set back to false.
R-TS-2 · The strict-family extras¶
Rule. Beyond strict, the config enables noUncheckedIndexedAccess,
exactOptionalPropertyTypes, and noImplicitOverride.
Why. strict alone still lets arr[i] be typed as non-undefined (it isn't — the
index may be out of range) and lets { x?: T } be assigned undefined where the property
was meant to be absent. In a client app fed by external JSON, out-of-range and
maybe-present are exactly the shapes that arrive. These flags make the compiler model
reality.
// tsconfig.app.json → compilerOptions
{
"noUncheckedIndexedAccess": true, // arr[i] is T | undefined
"exactOptionalPropertyTypes": true, // ?: means absent, not undefined
"noImplicitOverride": true // override keyword required
}
vcqa. All three keys present and true in the app tsconfig.
R-TS-3 · Split tsconfigs with a solution root¶
Rule. Config is split into tsconfig.app.json (browser sources, DOM lib, bundler
resolution) and tsconfig.node.json (Vite config and other Node-side files), referenced
from a thin root tsconfig.json.
Why. App code and the build config run in different environments with different globals
and libs. A single tsconfig either leaks Node types into browser code or DOM types into the
Vite config. Splitting them keeps each surface honest and lets tsc -b cache them
independently.
// tsconfig.json (root) — references only, no sources
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
vcqa. Root tsconfig.json has a references array and empty/absent files;
tsconfig.app.json and tsconfig.node.json exist.
R-TS-4 · Project references, built with tsc -b¶
Rule. The referenced projects set "composite": true, and the type-check runs in build
mode (tsc -b), not plain tsc.
Why. composite + tsc -b is what makes references real: incremental builds, correct
cross-project ordering, and a fast re-check that only touches what changed. tsc (no -b)
ignores the reference graph and either does the wrong thing or does too much.
vcqa. Referenced projects have composite: true; typecheck/build scripts invoke
tsc -b (build mode), not bare tsc.
R-TS-5 · Ban any; reach for unknown¶
Rule. No explicit any in source; unknown is used for genuinely-unknown values and
narrowed before use. noImplicitAny (via strict) stays on.
Why. any disables the type system locally and infects everything it touches — the one
place you cast to any is the one place a static app crashes for a user. unknown forces a
narrowing check, which is the honest way to handle a value whose shape you don't yet know.
// ❌ any — every downstream access is unchecked
function parse(raw: any) { return raw.items.map((x) => x.id); }
// ✅ unknown — must be narrowed before use
function parse(raw: unknown): string[] {
if (!isPayload(raw)) throw new Error('bad payload');
return raw.items.map((x) => x.id);
}
vcqa. No : any, as any, or <any> in src/**; presence of an ESLint rule such as
@typescript-eslint/no-explicit-any (error) is a positive signal.
R-TS-6 · Type component props explicitly; no React.FC¶
Rule. Every component declares an explicit props type (interface or type alias) and is
written as a plain function — not typed via React.FC / React.FunctionComponent.
Why. An explicit props type is the component's contract, checked at every call site.
React.FC adds an implicit children to components that don't take children, weakens
defaultProps inference, and complicates generics — the community moved off it. A plain
typed function is clearer and stricter.
// ❌ React.FC — implicit children, awkward generics
const Badge: React.FC<{ count: number }> = ({ count }) => <span>{count}</span>;
// ✅ explicit props, plain function
type BadgeProps = { count: number };
function Badge({ count }: BadgeProps) {
return <span>{count}</span>;
}
vcqa. No React.FC / React.FunctionComponent annotations; exported components have a
declared props type (not an inline untyped destructure with implicit any).
R-TS-7 · Typed API boundaries — validate, don't just cast¶
Rule. Every fetch/SDK response is given a declared TypeScript type and its runtime
shape is validated before use; await res.json() as SomeType with no runtime check fails
this rule.
Why. as SomeType is a lie the compiler believes and the network doesn't honor. A
static SPA's data crosses an untrusted boundary — an external API, a CDN'd JSON file, a
platform SDK — and a shape mismatch there surfaces as a render crash on the user's screen
with no server log. The type is the design; validation is the guard. (Schema mechanics live
in State & data and Forms — here the requirement is only
that the boundary is both typed and checked.)
// ❌ cast — the type is fiction if the API drifts
const user = (await res.json()) as User;
// ✅ typed + validated (schema library detailed on the data page)
const user: User = UserSchema.parse(await res.json());
vcqa. Response objects flowing from res.json() / SDK calls are validated (a schema
.parse/.safeParse or an explicit type-guard) rather than only as-cast; a bare
as on a json() result is a finding.
R-TS-8 · verbatimModuleSyntax and import type¶
Rule. verbatimModuleSyntax: true is set, and type-only imports use import type
(or inline import { type X }).
Why. With verbatimModuleSyntax the emit follows exactly what you wrote — a value
import stays, a type import is erased. This prevents a type-only symbol from being emitted
as a runtime import that pulls a module (and side effects) into the bundle a static app
ships. It also keeps imports unambiguous for the bundler's tree-shaking.
import { type Route, createBrowserRouter } from 'react-router-dom'; // ✅ inline
import type { User } from './types'; // ✅ erased at build
vcqa. verbatimModuleSyntax: true in the app tsconfig; type-only imports carry the
type modifier (a lint like @typescript-eslint/consistent-type-imports is a positive
signal).
R-TS-9 · No @ts-ignore; use @ts-expect-error with a reason¶
Rule. Suppressions use @ts-expect-error accompanied by a short reason comment; bare
@ts-ignore is banned.
Why. @ts-expect-error fails the build if the error it silences ever goes away, so a
suppression can't rot into a lie — it self-cleans. @ts-ignore silences forever and hides
the moment the underlying bug is fixed or a new one appears. The reason text tells the next
reader why the escape hatch was needed.
// ❌ silent forever
// @ts-ignore
widget.legacyInit();
// ✅ self-expiring, explained
// @ts-expect-error — upstream types missing legacyInit until v3, tracked in #142
widget.legacyInit();
vcqa. No @ts-ignore in src/**; any @ts-expect-error is followed by explanatory
text (ts-expect-error with a description is enforceable via
@typescript-eslint/ban-ts-comment).
R-TS-10 · Path aliases defined in both tsconfig and Vite¶
Rule. Import aliases (e.g. @/* → src/*) are declared in tsconfig paths and
mirrored in vite.config.ts resolve.alias; every alias resolves in both places.
Why. TypeScript resolves paths for type-checking, but Vite resolves imports for the
actual bundle — they are two separate resolvers. If only tsconfig knows the alias, the type
check passes and the build fails (or worse, ships a broken chunk). Both must agree, or the
static output is wrong even though the editor is green.
vcqa. For each paths alias in tsconfig there is a matching resolve.alias entry in
the Vite config (and vice-versa); a mismatch is a finding.
Checklist¶
-
strict: truein the app tsconfig (R-TS-1) -
noUncheckedIndexedAccess+exactOptionalPropertyTypes+noImplicitOverrideon (R-TS-2) - Split
tsconfig.app.json/tsconfig.node.jsonunder a references-only root (R-TS-3) -
composite: true+ type-check viatsc -b(R-TS-4) - No explicit
any;unknown+ narrowing instead (R-TS-5) - Explicit prop types; no
React.FC(R-TS-6) - API responses typed and runtime-validated, never bare-cast (R-TS-7)
-
verbatimModuleSyntax: true;import typefor type-only imports (R-TS-8) - No
@ts-ignore;@ts-expect-errorwith a reason (R-TS-9) - Path aliases mirrored in tsconfig
pathsand Viteresolve.alias(R-TS-10)