Skip to Content
GatesFEFE patternsType-safetyen

Type-safety

The input is a shape somebody already accepted — a layout, a block, a capability or a contract that is no longer being argued about. The output is source architecture: which file holds the value that comes in from outside, what that file may declare, what it may not assert, and what it owes in writing when it does assert. This module never re-opens the accepted shape. It lands it, at the one place the shape is silently allowed to stop being checked.

Law

Types are the half of this canon a machine holds without being asked. Most of what the other modules say is held by a closed union or a slot alias rather than by a lint rule — which means the value of the type system here is not “fewer bugs” in the abstract. It is that most of canon stops being optional.

That gives this module one job: guard the places where somebody turns the type system OFF. A cast does not fix a type error; it silences one, at the exact seam where the error was worth having.

The question that settles every code below:

What did the compiler know that this line is telling it to forget?

If the answer is “nothing, the types genuinely match”, the cast is unnecessary. If the answer is anything else, the cast is hiding it.

This is binding, not advisory. Every erasure in the source is one of the five situations below. There is no size at which an erasure is too small to carry a code: a one-line as unknown as in a helper is TYPE-SAFETY-1 for the same reason a module-wide any is TYPE-SAFETY-2. “It is only one line” is not an exemption — it is the sentence that opens the seam.

Situation codes

Every situation this module governs carries a code, TYPE-SAFETY-<n>. The code names the SITUATION; the columns name what that situation requires of the source and what it forecloses.

CodeSituationWhat the source must look like
TYPE-SAFETY-1A value crosses into the program and the writer casts it through unknownA value crossing into the program is narrowed by a check the compiler can follow. Forbidden: a cast through unknownx as unknown as T — in governed product source
TYPE-SAFETY-2The shape is genuinely unknown and the writer reaches for anyA genuinely unknown shape is declared unknown, so the narrowing has to happen somewhere in the open. Forbidden: any, in a declaration, a parameter, a generic argument or a cast
TYPE-SAFETY-3An array type is spelled two different ways in one treeOne spelling for an array type: Array<T> and ReadonlyArray<T>. Forbidden: T[] and readonly T[]
TYPE-SAFETY-4A test has to construct the value the types forbid, because that is what it provesThe permission to build a value the types forbid is a PATH — a .test. or .spec. file — and the wrong value is what the file is proving. Forbidden: a judgement-based exemption argued at a call site; a product file claiming the test permission
TYPE-SAFETY-5A cast at a real boundary survives reviewA cast that survives review states its reason in a clause beside it. Forbidden: a cast whose only justification is that the error went away

Two codes name an absence of a mechanism rather than a value. TYPE-SAFETY-4 is a permission, not a prohibition: it is the only code here that says yes, and it exists so that the no in TYPE-SAFETY-1 can be absolute everywhere else. TYPE-SAFETY-5 governs a comment, which is the one thing on this list no compiler reads. Both are real situations a reader has to be able to cite: a module that can only describe what a checker sees cannot correct the cases the checker was deliberately not given.

Reading an accepted shape

  1. Read what the shape states. It states that some value reaches this surface: a response body, a stored record, a decoded token, a vendor event, a contract prop. That fact is settled; it is not re-opened here.
  2. Read what the shape does not state, and therefore does not resolve. A shape never states what a type should contain — that belongs to the contract and props modules — and never states that a value is validated at runtime. This module governs only the moment the compiler is told to stop looking.
  3. Resolve outermost first. Start at the file where the value enters the program and work inward. The seam a double cast erases is the outermost one, and every inner file inherits whatever that file decided.
  4. Ask each code’s question, in order. Does the file cast through unknown (TYPE-SAFETY-1)? Does it write any where the shape is genuinely unknown (TYPE-SAFETY-2)? Does it spell an array type twice over (TYPE-SAFETY-3)? Is the path a .test./.spec. file whose subject is the wrong value (TYPE-SAFETY-4)? Does a surviving cast carry its reason in a clause (TYPE-SAFETY-5)?
  5. When two codes both match, choose the code with the right radius. An erasure that stops at one line is TYPE-SAFETY-1; a type that travels with the value into files that never named it is TYPE-SAFETY-2. Same act, different radius — and the travelling one costs more.

TYPE-SAFETY-1 — a cast through unknown is erasure, not narrowing

Situation. A value has just crossed from outside the program to inside it: a network response, something read from storage, a payload somebody else sent, a vendor type that does not match. It does not have the shape the writer wants. Instead of checking it, the writer tells the compiler to forget everything with x as unknown as T.

What it emits in source. A governed file under /src/ that declares the incoming value and narrows it with a check the compiler can follow — a predicate, a typeof, a discriminant. The string as unknown as does not appear in that file. A single-step cast such as a as B is still a claim the compiler can partly check: it refuses when the two types share nothing. Routing through unknown is exactly how that partial check is bypassed, because unknown overlaps every type. That is not narrowing. That is erasure — and what it erases is the seam most worth keeping. Inside the program a wrong cast is usually caught by other types a few lines later. At the boundary nothing catches it; the wrong data travels on until it breaks somewhere unrelated.

Recognition signs. The literal as unknown as in a non-test file. A cast sitting immediately after JSON.parse, response.json(), localStorage.getItem, or a vendor import. A stated reason of the form TypeScript complains, it will not accept it, I know what shape it is. Ask: if the server renames a field tomorrow, does this line go red? If not, nobody is checking, and this cast is where the checking was switched off.

Boundary. Not TYPE-SAFETY-2: this erases at one line, while any erases and then travels with the value into every file it touches. Not TYPE-SAFETY-4: same syntax, different file — in a .test./.spec. file, building the wrong value is the file’s job. Not TYPE-SAFETY-5: that code covers a single-step cast that can still hold a reason; a cast through unknown is not rescued by a comment, because a reason does not turn erasure back into checking. And a cast into unknownvalue as unknown alone — is not this code at all: it moves a value from a type the compiler should not have believed to one it cannot act on without a check, the opposite direction and the direction this law wants.

Common business situations. A REST response body · the decoded payload of a token · a record read from localStorage · a vendor type declared wrongly · a third-party library event · seed data pushed into a function with a closed type.

TYPE-SAFETY-2any is the same erasure, and it spreads

Situation. The real shape is genuinely not known yet, so the writer puts any and moves on. The difference from TYPE-SAFETY-1 is not how serious one line is; it is radius.

What it emits in source. A declaration, parameter or generic argument typed unknown, with the narrowing visible in the file that needs it — a local predicate, an isRecord, a typeof chain — and no any anywhere in the file. A cast stops at its line. any travels: every property read off it is any, every value derived from it is any, and the erasure reaches files that never mentioned it. The next reader opens a clean file, sees a variable with a type, and has no way to know that type stopped being checked three files ago. unknown does not lie: it says “not known yet”, and it forces the narrowing to happen somewhere in the open.

Recognition signs. : any, <any>, as any, Array<any>, Record<string, any>. A function that takes any and returns something typed, with no checking step in between. A stated reason of the form temporary, will fix later, this place is too generic. Ask: if this any became unknown, how many places go red? Each one is a place trusting something nobody checked.

Boundary. Not TYPE-SAFETY-1: if both could apply, take the code with the right radius — one line is TYPE-SAFETY-1, a type that spreads is TYPE-SAFETY-2. Not TYPE-SAFETY-5: any is not rescued by a reason, because a reason justifies bridging at one point and any is not a point.

Common business situations. A wrapper around an HTTP client · a handler taking a webhook payload · an adapter translating vendor data · a long-shared “util” function that lost its types · placeholder props on a freshly built component.

TYPE-SAFETY-3 — one thing, one spelling

Situation. Array<T> and T[] mean exactly the same thing. That is precisely why this is a law and not a preference: when two spellings are both correct, nothing ever fixes the second one.

What it emits in source. Every array type in the tree written in the generic form — Array<T> and ReadonlyArray<T> — including where the element type is itself generic or exotic. Choose the generic form because it stays readable when the element type is itself generic. Compare Array<Map<string, Set<number>>> with Map<string, Set<number>>[]: in the postfix form the brackets that say “this is an array” are pushed to the very end, after the eye has already unpacked two other generic layers. A file written on Tuesday reads differently from the file beside it, and every later diff carries noise that says nothing about the business.

Recognition signs. T[] or readonly T[] in a .ts/.tsx file. Both spellings living in one file. Ask: if the element type becomes generic tomorrow, is this line still readable?

Boundary. Not any other code here: this is the only one that is not about switching checking off. Nothing is erased; the type system keeps working as usual. It sits in this module for the same root reason — what nobody fixes will drift.

Common business situations. A component’s data type · a query result · a handler’s rest parameter · a union containing nested arrays · a contract’s readonly type.

TYPE-SAFETY-4 — a test may build the wrong value, because that is its job

Situation. Something has to prove that a type-closed API refuses bad input. Proving that means constructing bad input — and there is no way to construct a value the types forbid without telling the compiler to forget them.

What it emits in source. A file whose path ends in .test.ts, .test.tsx, .spec.ts or .spec.tsx, holding the deliberately wrong value, with the production file left clean. This is the only code in the module that says permitted. It exists precisely so that the no in TYPE-SAFETY-1 can be absolute everywhere else. The exemption is a PATH, and has to be a path. A judgement-based exemption — “when it is truly necessary it is allowed” — gets re-argued at every call site, and the side arguing is always the side in a hurry. A path is argued once, here.

Recognition signs. The file ends in .test.ts, .test.tsx, .spec.ts, .spec.tsx. The value built is a fake that is deliberately incomplete: enough for the function under test to touch, not enough to match the real type. A sentence nearby makes clear what this file is guarding. Ask: is this wrong value the thing being proved? If it is only a quick fixture, the exemption does not apply — it is borrowing another code’s permission.

Boundary. Not TYPE-SAFETY-1: same syntax, different file. That is the whole difference, and also why the exemption must be a path rather than a promise. Not TYPE-SAFETY-5: inside a test a reason is not the condition for the cast to exist; a sentence saying what the file guards is still what makes it readable later, but that is habit, not law. A test file is not automatically clean. The exemption says only that building a wrong value here is not a fault. It does not say every cast in a test is right. A lazy cast in a test is still a lazy cast — there is just nothing reporting it.

Common business situations. Faking a transport link operation · building a response missing a required field · forcing an undefined into a slot declared Error to see the defensive branch run · mocking a vendor module out of minimal fragments · building an invalid state to exercise a guard.

TYPE-SAFETY-5 — a surviving cast carries its reason

Situation. Sometimes a boundary genuinely needs a cast: a vendor type declared wrongly, a value the runtime guarantees and the compiler cannot, an implementation wider than any of its own overloads. Those cases are real.

What it emits in source. A single-step cast with a clause beside it naming what the runtime guarantees or what the vendor declared wrongly, and a checking step still standing after the cast — the cast opens just enough room to check, it does not replace the check. What separates these from the rest is not the writer’s confidence but that the reason can be written as a clause. That test is stronger than it looks: forced to write the sentence, most casts collapse, because the only sentence available is “otherwise it errors” — and that error was the compiler saying something true.

Recognition signs. A single-step cast, not routed through unknown. A sentence beside it stating a runtime guarantee or a vendor misdeclaration, not restating what the cast does. A narrowing step remaining after the cast. Ask: write the reason as one sentence. If the sentence is “because it errors”, the cast belongs to TYPE-SAFETY-1 or to a change of shape, not here.

Boundary. Not TYPE-SAFETY-1: a reason does not rescue a cast through unknown; erasure with an explanation is still erasure. Not TYPE-SAFETY-2: a reason does not rescue any either, because any does not stop at the line carrying the reason. Not TYPE-SAFETY-4: inside a test a cast needs no permission; outside a test it does. No rule holds this code, and none can. A machine sees that a comment exists; it cannot see that the comment is true, and a rule demanding “there must be a comment” is satisfied by the word cast. This is the one place in the module where the reader is the only mechanism.

Common business situations. The implementation of an overloaded factory · public claims decoded from a token · a vendor .d.ts missing a field the runtime always sends · a literal that must keep its narrow type · a branded type constructed at exactly one checked place.

Layer held

Which tier actually holds each code. unrepresentable means a closed union or branded type makes the wrong value impossible to write; enforced means a rule in sources/fe/type-safety.mjs reports it, named below; documented means nothing in this module’s rule file holds it and only a reader does.

CodeTierWhat holds it
TYPE-SAFETY-1enforcedno-double-cast, messageId double. Reports the outer cast of an x as unknown as T pair, in any file matching /src/ that is not .test./.spec.. Exact — it matches one syntactic shape — and complete for that shape.
TYPE-SAFETY-2documentedNothing in this file, on purpose. The TypeScript plugin’s own @typescript-eslint/no-explicit-any refuses any, and reimplementing it here would be a second copy of somebody else’s rule — a second thing to keep in step, and the one nobody edits is the one that stops matching. Held outside the module, at a known cost: this module cannot state the severity that rule runs at.
TYPE-SAFETY-3documentedNothing in this file, for the same reason. The array spelling is a formatter-shaped question already answered by @typescript-eslint/array-type with { default: "generic", readonly: "generic" }. What no rule holds is the reason — that the generic form stays readable when the element type is itself generic.
TYPE-SAFETY-4documentedThe exemption is IMPLEMENTED by no-double-cast (isTestFile, isGoverned), so a product file cannot claim it — but that half is reported as TYPE-SAFETY-1. The half that belongs to this code, that the wrong value is what the test is PROVING, is held by nobody. A test that casts through unknown out of laziness passes silently.
TYPE-SAFETY-5documentedNo rule reads a reason. A checker can see that a comment exists; it cannot see that the comment is true, and a rule that demanded any comment at all would be satisfied by the word cast.

One code is held by a rule; four are held by a reader. The four are not a backlog to be silently closed. Two (TYPE-SAFETY-2, TYPE-SAFETY-3) are deliberate hand-offs to rules a consuming repository already has, and two (TYPE-SAFETY-4, TYPE-SAFETY-5) are the part of this law that a checker cannot be given without becoming a formality.

Anchor

A law that cannot be pointed at in real code is a proposal. Paths are relative to the front-end source tree.

CodeAnchorWhat to look for
TYPE-SAFETY-1src/components/contracts/props.ts, and the tree-wide absenceThe contract types are declared, never asserted into place. Tree-wide, every occurrence of as unknown as sits in a .test./.spec. file; there is no governed source file that contains one. That absence is the anchor — this code’s evidence is a count of zero, and a single new hit is the whole finding.
TYPE-SAFETY-2src/modules/code/sandbox-repo.ts, src/components/leaves/Article/index.tsxBoth take the outside value as unknownparseSandboxRepoSnapshot(raw: unknown), toNode(value: unknown) — and each carries a local isRecord predicate that narrows it. The narrowing is visible in the file that needs it, which is what unknown buys and any spends.
TYPE-SAFETY-3src/components/contracts/props.tsReadonlyArray<DataValue> inside the DataValue union and Array<never> in ComponentActions. Both are element types that are themselves generic or exotic, which is where the spelling stops being cosmetic.
TYPE-SAFETY-4src/modules/api/graphql/clients/links/bearer.test.tsA fake transport operation is assembled from the three methods the link touches and returned through a double cast. The file’s own doc comment states what it guards; the cast exists because proving a closed API refuses a malformed operation means constructing one.
TYPE-SAFETY-5src/hooks/auth/useSessionRefresh.ts, src/components/contracts/props.tsIn the first, the cast target is { exp?: unknown } and the doc line above says why the shape is not trusted further; the value is then narrowed with typeof. In the second, the implementation of an overloaded factory is cast to its own overload set, under a doc comment that says which surface is the checked one. Counter-case in the same tree: src/app/sitemap.ts casts a response body to a named type with no reason clause and nothing reports it.

Inputs

InputEvidence required
file pathWhether the file is governed product source (/src/) or a .test./.spec. file
erasure shapeDouble cast through unknown, an any annotation, or a single cast
origin of the valueWhether it crossed into the program from outside — network, storage, a vendor type — or was built inside it
what the compiler knewThe type in force on the line before the erasure
reachWhether the erasure stops at this line or travels with the value
reasonFor a surviving cast: the sentence that would be written beside it

Rules

  1. No as unknown as in product source under /src/.
  2. A cast is an erasure, not a narrowing. A narrowing is a claim the compiler can still partly check.
  3. The seam a double cast erases is the one worth checking: where a value crosses from outside the program to inside it.
  4. unknown forces a narrowing to happen somewhere visible; any removes the requirement to have one.
  5. An erasure that stops at one line and an erasure that travels are different sizes of the same act, and the travelling one costs more.
  6. One thing has one spelling. Where two spellings mean the same thing, the module picks one and nothing is left to the day of the week.
  7. The test exemption is a path, decided once, not a judgement re-argued at every call site.
  8. A reason that cannot be written in a clause is a cast hiding something rather than bridging something.
  9. Each foreign rule this module hands a code to is named. A code handed to nobody is unheld, and this module says so rather than implying coverage.

Exceptions

Exceptions are part of the rule, not relief from it. Each is closed and cites the code it applies to.

  • Test files. TYPE-SAFETY-1 does not apply to a .test. or .spec. file. That is TYPE-SAFETY-4, and it is narrow because it is a path: proving a closed API refuses bad input requires constructing bad input, and there is no way to construct a value the types forbid without telling the compiler to forget them.
  • Outside /src/. Tooling, build config and scripts are out of scope for TYPE-SAFETY-1. The law governs the program, not the machinery that assembles it.
  • A genuine boundary, with its reason. TYPE-SAFETY-5 admits the cast that survives: a vendor type that is wrong, a value the runtime guarantees and the compiler cannot. Those exist. What separates them from the others is that the reason can be written in a clause.
  • Widening to unknown. A single cast to unknown is not a TYPE-SAFETY-1 erasure. It moves a value from a type the compiler should not have believed to one it cannot act on without a check — the opposite direction, and the direction this module wants.
  • The overload implementation. TYPE-SAFETY-5 admits a cast from an implementation signature to its own overload set, because the overloads are the checked surface and the implementation is deliberately wider than any single one of them.

Output

One block per file the accepted shape produces.

file: <path under the source tree> governed: <yes | no — test file | no — outside /src/> situation: <TYPE-SAFETY-1 … TYPE-SAFETY-5> erasure: <double cast | any | single cast | none> verdict: <refused | permitted | permitted with reason> holder: <no-double-cast | foreign rule name | reader> reason: <what the compiler knew, and why it may or may not forget it>

Worked example

Accepted shape. A session surface refreshes itself from a token the browser already holds, reads a repository snapshot back from a sandbox service, exposes its props through an overloaded factory, and is guarded by a test that proves the transport link refuses a malformed operation.

The shape states that these values arrive and where they are consumed. It does not state what the token’s claims contain, what the snapshot’s fields are called, or whether anything validates them at runtime — so it does not resolve those. Contract content belongs to the contract and props modules; runtime validation belongs to nothing in this module. What the shape does resolve is the moment each file is allowed to tell the compiler to stop looking.

file: src/hooks/auth/useSessionRefresh.ts governed: yes situation: TYPE-SAFETY-5 erasure: single cast verdict: permitted with reason holder: reader reason: the cast target is `{ exp?: unknown }` and the value is narrowed with `typeof` afterwards — it is a single-step cast that opens just enough room to check, not a route through `unknown`, which is the fact that excludes TYPE-SAFETY-1
file: src/modules/code/sandbox-repo.ts governed: yes situation: TYPE-SAFETY-2 erasure: none verdict: permitted holder: foreign rule name — @typescript-eslint/no-explicit-any reason: the snapshot enters as `parseSandboxRepoSnapshot(raw: unknown)` and a local `isRecord` predicate narrows it in the file that needs it — the erasure would have travelled with the value into every consumer, which is the radius that makes this TYPE-SAFETY-2 and not TYPE-SAFETY-1
file: src/components/contracts/props.ts governed: yes situation: TYPE-SAFETY-3 erasure: none verdict: permitted holder: foreign rule name — @typescript-eslint/array-type reason: the array types are spelled `ReadonlyArray<DataValue>` and `Array<never>`, whose element types are themselves generic or exotic — nothing is erased here at all, which is the fact that excludes every other code in this module
file: src/modules/api/graphql/clients/links/bearer.test.ts governed: no — test file situation: TYPE-SAFETY-4 erasure: double cast verdict: permitted holder: reader reason: the fake transport operation is assembled from the three methods the link touches and the malformed operation is exactly what the file proves is refused — the path ends in `.test.ts`, which is the fact that excludes TYPE-SAFETY-1
file: src/app/sitemap.ts governed: yes situation: TYPE-SAFETY-1 erasure: double cast verdict: refused holder: no-double-cast reason: the response body crossed into the program from the network and the compiler knew only that it was unvalidated — a route through `unknown` erases that at the one seam worth checking, and no reason clause converts erasure back into checking, which is the fact that excludes TYPE-SAFETY-5

Scope

This rule holds for any code of this kind in this stack: any front-end file that turns type checking off inside one source tree. It names no single feature, no product and no library brand. Anchors cite real paths because a pattern module owes a place to check; every worked example is ordinary TSX or TS against placeholder modules.

It does not govern what a type should contain — that belongs to the contract and props modules — and it does not govern runtime validation. It governs only the moment the compiler is told to stop looking.