TypeScript
Type evidence in every .ts/.tsx file: unknown at the boundary only, no widening, no reflective escapes.
What this is
Type evidence in every .ts/.tsx file: unknown at the boundary only, no widening, no reflective escapes.
How to onboard it
A repository picks up this stack by onboarding on a profile that includes it. redline init detects the profile from what is in the repository, so in most cases this is automatic:
$ npx redlinegate init # detects the profile $ npx redlinegate init --profile fullstack-react-node # or name one
12 profiles pull these rules in: fullstack-react-node, javascript-typescript, javascript-typescript-browser, mobile-react-native, service-express, service-node, typescript-effect, web-angular, web-nextjs, web-react, web-svelte, web-vue.
Once onboarded, your files match this stack when they fit any of these globs:
**/*.ts**/*.tsx**/*.mts**/*.cts
How to use it — 13 rules
Nothing to run. Once your profile includes TypeScript, redline init renders these rules into your repository's AI tooling and the reviewer applies them on every pull request. When a review comment cites one of these ids, this table is where to look up what it catches and why.
| Rule id | Severity | Catches |
|---|---|---|
typescript/widen-then-assert | BLOCKER | Widen, then assert back. A value with a known type is bound to `unknown`, `any`, `object` or an open record (`const raw: unknown = user`, `const raw = user as unknown`) and later asserted to a narrower type (`raw as Account`). It is a double assertion split across lines: the compiler checks neither half, so a wrong target type reaches runtime unchecked. Keep the original type, or parse. |
typescript/unknown-parameter | HIGH | A parameter typed `unknown` (or a union containing it) on a function that is not the boundary parser or a type predicate's subject. Every caller already holds a typed value, and the function re-checks or asserts it — parse at the boundary and accept the domain type. |
typescript/unknown-return | HIGH | An explicit return type of `unknown`, `Promise<unknown>` or `PromiseLike<unknown>`. Every caller now has to parse or assert the result; parse it once inside the function and return the domain type. |
typescript/unknown-type-alias | HIGH | A type alias that is `unknown` or a union containing it (`type Payload = unknown`). The name hides the top type, so readers and reviewers treat it as a contract it is not. |
typescript/open-dictionary-value | HIGH | An open dictionary whose value type is `unknown`, `any`, `object` or `{}` (`Record<string, unknown>`, `{ [key: string]: unknown }`). Every read needs an unchecked assertion. Not when it is a generic constraint (`T extends Record<string, unknown>`), a finite key set (`Record<Status, number>`), the subject of a type predicate or parser at the boundary (`value is Record<string, unknown>`), or a bag a specification defines as open (SARIF `properties`, GraphQL `variables`). |
typescript/object-parameter | HIGH | A parameter typed `object`. It accepts arrays, functions and class instances, and allows no property access without an assertion. Accept a named type, or a generic `<T extends object>`. |
typescript/known-value-widening | HIGH | A literal or known-typed value assigned to an annotation that discards what is known: `const routes: Record<string, Handler> = { home, about }` makes `routes.contact` type-check as a `Handler` that is `undefined` at runtime. Drop the annotation, or use `satisfies`. |
typescript/reflect-dynamic-access | HIGH | `Reflect.apply` or `Reflect.get` in application code. Both take untyped arguments and return `any`, so the call and its result escape type checking. Use a typed call or property access; dynamic dispatch goes behind a named interface. |
typescript/module-mocking | HIGH | Module mocking in tests (`vi.mock`, `vi.doMock`, `jest.mock`, `jest.doMock`, `unstable_mockModule`). The test replaces the import graph, so it keeps passing when the real module's contract changes or its wiring breaks. Inject the dependency through a real interface and pass a faithful test implementation; `vi.spyOn` on an injected object is fine. |
typescript/reduce-accumulator-copy | HIGH | A `reduce` callback that copies its accumulator on every iteration (`Object.assign({}, acc, …)`, `acc.concat(…)`, `[...acc, x]`, `{ ...acc, [k]: v }`). The work grows quadratically with the input. Mutate an accumulator the reducer owns and return it, or use `flatMap`/`Object.fromEntries`. |
typescript/ad-hoc-typeof-narrowing | SUGGESTION | `typeof` checks scattered through domain logic to work out what a value is (`typeof input === 'string'`), when the value should have arrived already parsed. Not in a type predicate, a boundary parser, or an existence probe (`typeof window === 'undefined'`). |
typescript/filter-map-double-pass | SUGGESTION | Adjacent `.filter(…).map(…)` or `.map(…).filter(…)` on a large or hot array — two passes and an intermediate array. `flatMap`, or iterator helpers (`.values().filter().map().toArray()`) where the runtime supports them. Not on small fixed arrays, where the readable version wins. |
typescript/conditional-empty-spread | SUGGESTION | `{ ...(cond ? { key } : {}) }` to add a property conditionally. Build the object, then assign the property in an `if` — the intent is visible and the key's absence is explicit. |
Expected output
A finding from this file, and every finding Redline produces, opens with a machine-readable first line — severity, then the rule id in brackets, then the problem in one line:
Redline/BLOCKER [typescript/widen-then-assert]: <one-line problem>Ids are aggregated per rule, which is how the organisation finds out which rules earn their place and which only generate noise — so a finding without a valid id cannot be measured and counts as untagged. Of the 13 rules here, 1 BLOCKER, 9 HIGH and 3 SUGGESTION. Only a BLOCKER must not merge; a SUGGESTION may be dismissed without justification, and is never upgraded to get attention.
If a rule here fires constantly on code your team has deliberately decided to allow, that is the signal to raise with the standards owner — the rule id is what makes that conversation measurable — not to argue it away comment by comment.
How to edit it
Rules are edited in standards/stacks/typescript.md and nowhere else. The rendered copies in AGENTS.md, .github/copilot-instructions.md and .github/instructions/ are generated and are overwritten by the next render. A change here propagates to every onboarded repository as a pull request, so treat it as a production change.
- Edit the markdownstandards/ is the only place a human edits a rule. Everything under AGENTS.md, .github/copilot-instructions.md and .github/instructions/ is rendered from it and is overwritten by the next render.
- node scripts/assign-rule-ids.mjsAssigns a permanent <stack>/<slug> id to any new rule bullet and rewrites the file in place. Do not invent an id by hand. CI runs the same script with --check and fails if a rule is missing one.
- node scripts/render-self.mjsRe-renders this repository's own artifacts from the edited source. CI runs it with --check, so stale checked-in output fails the build.
- Bump standards/manifest.json → versionRequired in the same pull request as the rule change. Sync pull requests quote the version, so a repository's rendered artifacts always name where they came from.
- Add a CHANGELOG.md entryAlso in the same pull request. A standards change with no measurement is an opinion — record the seed score alongside it.
- node scripts/validate.mjsThe bundle self-check CI runs: manifest integrity, well-formed rule ids, the severity output contract surviving your edit, glob portability.
Rule ids are permanent. Rewording a rule is fine and keeps its id; renaming or removing an id orphans every historical telemetry record that cited it.
The full file
# TypeScript Review Rules
**Scope:** every `.ts`, `.tsx`, `.mts` and `.cts` file, alongside whichever framework stack
also applies. These rules are about type evidence: a value whose type the code already knows
should keep that type from where it is created to where it is used, and a value whose type it
does not know should be parsed once, at the boundary it arrived through. The core standard's
type-safety rules (`core/escape-hatch-types`, `core/unsafe-assertion`) still apply and are not
repeated here.
A function whose whole job is to parse external input — a schema `parse`, a type predicate
`(value: unknown): value is T`, a decoder at an HTTP or queue boundary — is where `unknown`
belongs. Do not flag `unknown` there, or on an error's `cause`.
## BLOCKER — request changes
- `typescript/widen-then-assert` — **Widen, then assert back.** A value with a known type is bound to `unknown`,
`any`, `object` or an open record (`const raw: unknown = user`, `const raw = user as unknown`) and later asserted
to a narrower type (`raw as Account`). It is a double assertion split across lines: the compiler checks neither
half, so a wrong target type reaches runtime unchecked. Keep the original type, or parse.
## HIGH
- `typescript/unknown-parameter` — A parameter typed `unknown` (or a union containing it) on a function that is not
the boundary parser or a type predicate's subject. Every caller already holds a typed value, and the function
re-checks or asserts it — parse at the boundary and accept the domain type.
- `typescript/unknown-return` — An explicit return type of `unknown`, `Promise<unknown>` or `PromiseLike<unknown>`.
Every caller now has to parse or assert the result; parse it once inside the function and return the domain type.
- `typescript/unknown-type-alias` — A type alias that is `unknown` or a union containing it (`type Payload = unknown`).
The name hides the top type, so readers and reviewers treat it as a contract it is not.
- `typescript/open-dictionary-value` — An open dictionary whose value type is `unknown`, `any`, `object` or `{}`
(`Record<string, unknown>`, `{ [key: string]: unknown }`). Every read needs an unchecked assertion. Not when it is a
generic constraint (`T extends Record<string, unknown>`), a finite key set (`Record<Status, number>`), the subject
of a type predicate or parser at the boundary (`value is Record<string, unknown>`), or a bag a specification
defines as open (SARIF `properties`, GraphQL `variables`).
- `typescript/object-parameter` — A parameter typed `object`. It accepts arrays, functions and class instances, and
allows no property access without an assertion. Accept a named type, or a generic `<T extends object>`.
- `typescript/known-value-widening` — A literal or known-typed value assigned to an annotation that discards what is
known: `const routes: Record<string, Handler> = { home, about }` makes `routes.contact` type-check as a `Handler`
that is `undefined` at runtime. Drop the annotation, or use `satisfies`.
- `typescript/reflect-dynamic-access` — `Reflect.apply` or `Reflect.get` in application code. Both take untyped
arguments and return `any`, so the call and its result escape type checking. Use a typed call or property access;
dynamic dispatch goes behind a named interface.
- `typescript/module-mocking` — Module mocking in tests (`vi.mock`, `vi.doMock`, `jest.mock`, `jest.doMock`,
`unstable_mockModule`). The test replaces the import graph, so it keeps passing when the real module's contract
changes or its wiring breaks. Inject the dependency through a real interface and pass a faithful test
implementation; `vi.spyOn` on an injected object is fine.
- `typescript/reduce-accumulator-copy` — A `reduce` callback that copies its accumulator on every iteration
(`Object.assign({}, acc, …)`, `acc.concat(…)`, `[...acc, x]`, `{ ...acc, [k]: v }`). The work grows quadratically
with the input. Mutate an accumulator the reducer owns and return it, or use `flatMap`/`Object.fromEntries`.
## SUGGESTION
- `typescript/ad-hoc-typeof-narrowing` — `typeof` checks scattered through domain logic to work out what a value is
(`typeof input === 'string'`), when the value should have arrived already parsed. Not in a type predicate, a
boundary parser, or an existence probe (`typeof window === 'undefined'`).
- `typescript/filter-map-double-pass` — Adjacent `.filter(…).map(…)` or `.map(…).filter(…)` on a large or hot array —
two passes and an intermediate array. `flatMap`, or iterator helpers (`.values().filter().map().toArray()`) where the
runtime supports them. Not on small fixed arrays, where the readable version wins.
- `typescript/conditional-empty-spread` — `{ ...(cond ? { key } : {}) }` to add a property conditionally. Build the
object, then assign the property in an `if` — the intent is visible and the key's absence is explicit.