• 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

LevelWhat it turns on
offPlain TypeScript, the tsconfig as written
strictstrict, noUncheckedIndexedAccess, exactOptionalPropertyTypes, isolatedDeclarations, and rules S-01, S-04, S-10
soundstrict plus S-02, S-03, S-05 to S-09, and S-11 to S-14
contractssound 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:

  1. A pragma in the file's leading comments: // @ts-r off, // @ts-r strict, // @ts-r sound or // @ts-r contracts.
  2. The file matches exclude: off.
  3. The file matches include: the configured level.
  4. 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:

ModeDefault forWhat it means in sound files
trustedpackages with typesTheir .d.ts types are used as written; any becomes unknown
loosepackages without typesany stays any; listed as an unchecked boundary in the report
verifynone (opt in)Like trusted, and dev builds check returned values against the declared types
soundnone (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.

RuleLevelMain code
S-01: No anystrictTSR0101
S-02: No narrowing type assertionssoundTSR0201
S-03: No non-null assertionssoundTSR0301
S-04: Checked index access and exact optional propertiesstrictTSR0401, TSR0402
S-05: Mutable built-in containers are invariantsoundTSR0501
S-06: Methods are checked contravariantlysoundTSR0601
S-07: Type guards must be provedsoundTSR0701
S-08: Fields are initialised before this escapessoundTSR0801, TSR0802
S-09: Narrowings are dropped when they could be invalidatedsoundTSR0901
S-10: No floating promisesstrictTSR1001
S-11: Dependency trust boundariessoundTSR1101, TSR1102, TSR1103
S-12: No suppression comments, unchecked overload implementations or global augmentationsoundTSR1201, TSR1205
S-13: Checked exceptionssoundTSR1301, TSR1302 (opt-in)
S-14: Data from outside the program is unknown until parsedsoundTSR1401, 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 own TS errors.
  • // @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 off or // @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.

FunctionReturnsThrows
parse<T>(value)TParseError
safeParse<T>(value){ ok: true, value } | { ok: false, error }nothing
is<T>(value)value is Tnothing
assert<T>(value)asserts value is TParseError
parseJson<T>(text)TParseError, SyntaxError
fetchJson<T>(input, init?)Promise<T>ParseError
env<E>()E, with strings converted to numbers, booleans and literalsParseError
body<T>(req), query<T>(req), stored<T>(key), readJson<T>(path), args<T>()TParseError
guard<T>(){ parse, is } for generic codenothing
expect<T>(value, message)the value, not null or undefinedunchecked ExpectError
panic(message)neverunchecked 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 with import 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 of ts-r.toml, as above;
  • a tsrconfig.json next to your tsconfig, with comments allowed: { "sound": { "level": "sound", "include": ["src/**"] } };
  • a top-level "ts-r": { "sound": { ... } } key in tsconfig.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=off checks every file at off. --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-unsafe with a reason".
  • Problem matchers. Matchers written for tsc look for error TS and miss TSR codes, so ts-r ships its own. In VS Code tasks, use "problemMatcher": "$ts-r" (from the ts-r extension). In GitHub Actions, add echo "::add-matcher::node_modules/ts-r/problem-matcher.json" before running ts-r-check. Both read TS and TSR errors.
  • 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-build reads 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-dev shows 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 @throws tags are held to them. Requiring exported functions to declare what they throw (TSR1302) is opt-in with exports-declare-throws = true. Only panic(), expect() and the classes in unchecked-errors are unchecked.
  • Classes and branded types in parse<T>. Classes are checked with instanceof. Branded types need a registered refinement (see above).
  • Configuration. ts-r.toml, tsrconfig.json or the tsconfig "ts-r" key, one of them.
  • Codes. Sound errors keep the TSR prefix, 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 stay any. 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.