- Accepted
- Pre-alpha
Sound mode
An opt-in mode where a static type is a guarantee about the runtime value. 14 rules, parse<T> validators, escape hatches. Pre-alpha.
Status
Its design record, 0007: Sound mode, was accepted on 2026-10-15, once the owner had answered all of its open questions (see Decisions below). ts-r-check and ts-r-lsp report the 14 rules. ts-r-build, ts-r-dev and ts-r-run expand parse<T> calls. It is pre-alpha. Parts not built yet are marked below.
The problem
TypeScript's types describe what a value probably is. Many common patterns let a value's real shape drift away from its type without an error: any, x as User, x!, JSON.parse, unchecked index access, array covariance, type guards that lie, and so on. The compiler is fine with all of them, and the bug shows up at run time.
In a file that opts in to sound mode, a static type is a guarantee about the runtime value, unless a written escape hatch says otherwise.
Sound mode adds no syntax. Every sound file is ordinary TypeScript that tsc accepts, and sound mode only adds errors. tsc with strict: true is the lower bound: code tsc rejects stays rejected.
Levels
| Level | What it turns on |
|---|---|
off | Plain TypeScript, the tsconfig as written |
strict | strict, noUncheckedIndexedAccess, exactOptionalPropertyTypes, isolatedDeclarations, and rules S-01, S-04, S-10 |
sound | strict plus S-02, S-03, S-05 to S-09, and S-11 to S-14 |
contracts | sound plus runtime checks in dev builds |
The forced compiler options apply per file, not to the whole program, so files that don't opt in keep your tsconfig's behaviour.
Opting in
Nothing changes until you opt in. A file's level is the first of these that applies:
- A pragma in the file's leading comments:
// @ts-r off,// @ts-r strict,// @ts-r soundor// @ts-r contracts. - The file matches
exclude:off. - The file matches
include: the configuredlevel. - Otherwise
off.
.d.ts files, JavaScript files and everything under node_modules are always off, whatever the configuration says. The rules check how their values are used in sound files.
Dependencies
Your dependencies are never held to the rules. Each package gets a mode, found from package.json, the lockfile and node_modules:
| Mode | Default for | What it means in sound files |
|---|---|---|
trusted | packages with types | Their .d.ts types are used as written; any becomes unknown |
loose | packages without types | any stays any; listed as an unchecked boundary in the report |
verify | none (opt in) | Like trusted, and dev builds check returned values against the declared types |
sound | none (opt in) | The package's own source is held to the rules (workspace packages) |
The 14 rules
Each rule has its own diagnostic codes. The table links the main one.
| Rule | Level | Main code |
|---|---|---|
S-01: No any | strict | TSR0101 |
| S-02: No narrowing type assertions | sound | TSR0201 |
| S-03: No non-null assertions | sound | TSR0301 |
| S-04: Checked index access and exact optional properties | strict | TSR0401, TSR0402 |
| S-05: Mutable built-in containers are invariant | sound | TSR0501 |
| S-06: Methods are checked contravariantly | sound | TSR0601 |
| S-07: Type guards must be proved | sound | TSR0701 |
S-08: Fields are initialised before this escapes | sound | TSR0801, TSR0802 |
| S-09: Narrowings are dropped when they could be invalidated | sound | TSR0901 |
| S-10: No floating promises | strict | TSR1001 |
| S-11: Dependency trust boundaries | sound | TSR1101, TSR1102, TSR1103 |
| S-12: No suppression comments, unchecked overload implementations or global augmentation | sound | TSR1201, TSR1205 |
| S-13: Checked exceptions | sound | TSR1301, TSR1302 (opt-in) |
S-14: Data from outside the program is unknown until parsed | sound | TSR1401, TSR1402 |
Problems with generating a parse<T> validator are TSR1501 to TSR1504. Each rule's full text, with its false-positive risk and migration path, is in the design record.
A short example of S-14:
const user: User = JSON.parse(text); // TSR1401
const u = await fetchJson("/api/user"); // TSR1402: needs a type argument
const user = parse<User>(JSON.parse(text)); // ok
const u = await fetchJson<User>("/api/user"); // ok
const raw: unknown = JSON.parse(text); // ok: no claim made
Escape hatches
// @ts-r-unsafe: <reason>on its own line suppresses every sound diagnostic that starts on the next line. It never suppresses tsc's ownTSerrors.// @ts-r-unsafe S-02 S-03: <reason>limits it to those rules.- The reason is required (TSR0001). A hatch that suppresses nothing is TSR0002, so hatches can't outlive the code they excuse. An unknown rule name is TSR0003.
- A file pragma (
// @ts-r offor// @ts-r strict) lowers one file's level. - Package modes change how much a dependency is trusted.
@ts-ignore, @ts-expect-error and @ts-nocheck are not escape hatches in sound files; they are errors under S-12. Every hatch is listed in the coverage report with its file, line, rules and reason.
parse<T> and @ts-r.dev/guard
@ts-r.dev/guard is an npm package of type declarations and a small stub (not published yet). ts-r expands each call into a validator generated from the type you name, so parse<User>(data) checks data against User at run time and returns it typed. The generated code is inlined, so the output doesn't depend on the package at run time.
| Function | Returns | Throws |
|---|---|---|
parse<T>(value) | T | ParseError |
safeParse<T>(value) | { ok: true, value } | { ok: false, error } | nothing |
is<T>(value) | value is T | nothing |
assert<T>(value) | asserts value is T | ParseError |
parseJson<T>(text) | T | ParseError, SyntaxError |
fetchJson<T>(input, init?) | Promise<T> | ParseError |
env<E>() | E, with strings converted to numbers, booleans and literals | ParseError |
body<T>(req), query<T>(req), stored<T>(key), readJson<T>(path), args<T>() | T | ParseError |
guard<T>() | { parse, is } for generic code | nothing |
expect<T>(value, message) | the value, not null or undefined | unchecked ExpectError |
panic(message) | never | unchecked PanicError |
The validator is built from declared types only, never from inference, and the call needs an explicit type argument. If code that uses @ts-r.dev/guard is built by another tool without the plugin, the stub throws instead of trusting the data.
Besides primitives, literals, enums, objects, arrays, tuples, unions, template literals and the common utility types, parse<T> handles:
- Your classes, checked with
instanceof. The validator imports the class, so it must be exported, concrete and not generic. Abstract classes, classes imported withimport type, and unexported classes are TSR1501. - Branded and refined types, such as
type Email = string & { readonly __brand: "Email" }, when you register a predicate for them (below). A branded type with no registered predicate is TSR1501.
[sound.guard]
refinements = { Email = "./src/validators#isEmail" }
// src/validators.ts
export type Email = string & { readonly __brand: "Email" };
// @ts-r-unsafe S-07: a string matching the pattern is an Email by definition.
export function isEmail(v: unknown): v is Email {
return typeof v === "string" && /^[^@\s]+@[^@\s]+$/.test(v);
}
parse<Email>(x) checks the base type (string), then calls isEmail. The path is relative to the configuration file. Nothing in the predicate's body can prove a brand (S-07), so the predicate carries an @ts-r-unsafe hatch that says why it is the definition.
Configuration
In ts-r.toml:
[sound]
level = "sound" # level for included files: "strict" | "sound" | "contracts"
include = ["src/**"]
exclude = ["src/legacy/**", "**/*.test.ts"]
unchecked-errors = [] # S-13: extra error classes treated as unchecked
exports-declare-throws = false # S-13: true makes exported functions declare '@throws' (TSR1302)
[sound.packages]
default = "trusted" # typed packages
untyped = "loose" # packages without types
"lodash" = "verify"
"@acme/core" = "sound"
[sound.sources] # extra S-14 external sources: package = [exported paths]
"@acme/rpc" = ["Client.call"]
[sound.guard]
explain = "full" # "full" | "first" | "none"
max-depth = 512
max-validator-kb = 8
refinements = { Email = "./src/validators#isEmail" }
The same settings can live in one of three places, with the same keys in each:
- the
[sound]table ofts-r.toml, as above; - a
tsrconfig.jsonnext to your tsconfig, with comments allowed:{ "sound": { "level": "sound", "include": ["src/**"] } }; - a top-level
"ts-r": { "sound": { ... } }key intsconfig.json(tsc ignores unknown top-level keys).
Use only one. Configuring sound mode in more than one of them is TSR0020, which names every file involved. Nothing goes in compilerOptions.
Reporting
- ts-r-check prints sound diagnostics in the same stream and format as tsc's errors, for example
src/users.ts(12,17): error TSR0201: .... The summary line counts them separately, and exit codes match tsc's.--sound=offchecks every file atoff.--sound-report(or--sound-report=json) prints coverage: files and lines per level, every@ts-r-unsafe, unchecked boundaries, validators generated, and counts per rule. - ts-r-lsp publishes them with source
ts-r sound, a link to the rule's page, and quick fixes for several rules, including "Add@ts-r-unsafewith a reason". - Problem matchers. Matchers written for tsc look for
error TSand missTSRcodes, so ts-r ships its own. In VS Code tasks, use"problemMatcher": "$ts-r"(from the ts-r extension). In GitHub Actions, addecho "::add-matcher::node_modules/ts-r/problem-matcher.json"before runningts-r-check. Both readTSandTSRerrors. - ts-r-build, ts-r-dev and ts-r-run don't run the checker; they expand
parse<T>calls. Planned, not built yet:ts-r-buildreads the last check result, fails when a sound file's last result has errors, and warns (or fails with--require-check) when there is none.ts-r-devshows them in the overlay and keeps serving. - ts-r ci (planned) fails on any sound error.
--sound-baseline <file>fails when hatches or unchecked boundaries per file go up or coverage goes down.
Decisions
The owner answered the record's 18 open questions on 2026-10-15. The ones that change what you write:
- Overloads (S-12). Overload implementations are allowed. The body is checked once per overload signature, and a branch that returns the wrong type for an overload is TSR1205, which names the overload.
@throws(S-13). Only functions that already have@throwstags are held to them. Requiring exported functions to declare what they throw (TSR1302) is opt-in withexports-declare-throws = true. Onlypanic(),expect()and the classes inunchecked-errorsare unchecked.- Classes and branded types in
parse<T>. Classes are checked withinstanceof. Branded types need a registered refinement (see above). - Configuration.
ts-r.toml,tsrconfig.jsonor the tsconfig"ts-r"key, one of them. - Codes. Sound errors keep the
TSRprefix, and ts-r ships problem matchers for it.[sound] code-prefix = "TS"prints the numeric form (TS100201) instead. - Defaults kept as proposed. Values from untyped (
loose) packages stayany. Mutable object properties stay covariant (only built-in containers are invariant). S-09 is an error. Single rules can be switched off in[sound.rules], and the report marks the guarantee as partial.parse<T>keeps extra properties and returns its input. A build without an up-to-date check result warns and continues.
Each decision, with its switch, is in the design record.
Test cases
The rules come with test cases written before any code: 111 files that must be accepted and 120 that must be rejected, all clean under tsc 7.0.2 with strict: true. All 231 pass. They are described in the cases README.