Skip to main content

soundscript vs TypeScript

soundscript is a stricter subset of TypeScript. .sts rejects or tightens the parts of the language that break soundness.

The goal is simple: if a program is fully .sts, the checked type story should be sound. If the checker accepts an unsound .sts program, that is a soundscript bug. Interop is the explicit place where that closed-world guarantee stops.

What stays the same

soundscript does not introduce parser-level syntax extensions. You still write ordinary TypeScript and JavaScript syntax, and your existing TypeScript editor, lint, and formatting tooling can keep working.

Some teams already lint for a few of the patterns below. soundscript makes them part of .sts itself and checks them together with types, import boundaries, and assignability rules. The goal is not "fewer common pitfalls." The goal is a sound checked language inside .sts.

The biggest practical differences are:

  • .sts as the checked file extension
  • comment-attached annotations like // #[interop]
  • stricter checking rules
  • compiler-owned sts:* modules for the builtin surface

The first differences most teams notice

Some writable assignments stop being “probably fine”

TypeScript
const ids: string[] = ['a'];
const values: (string | number)[] = ids;
values.push(1);
soundscript
const ids: string[] = ['a'];
const values: Array<string | number> = [...ids];
values.push(1);

This matters when shared mutable data can be widened through one alias and then mutated somewhere else.

Interop stays visible

TypeScript
import { readLegacySession } from './legacy.ts';
soundscript
// #[interop]
import { readLegacySession } from './legacy.ts';

This matters anytime an .sts file imports regular .ts, .js, or declaration-only code.

Unchecked escapes are gone

TypeScript
const user = raw as User;
const id = maybeId!;
let cache: any = value;
soundscript
const user = parseUser(raw);

if (user === undefined) {
throw new Error('Invalid user.');
}
const id = user.id;

This matters in any module where “I know this is fine” becomes a real bug if the assumption is wrong.

Throws are disciplined locally

TypeScript
throw 'bad session';
soundscript
throw new Error('Bad session.');

Local .sts code throws Error values. If regular TS or JS throws something stranger, the runtime normalizes it automatically when that error keeps propagating.

Banned or restricted surfaces

The biggest restrictions are:

  • any
  • unchecked as assertions
  • non-null assertions
  • numeric enums
  • implicit truthiness checks where explicit checks are required
  • silent interop with ordinary .ts, .js, or .d.ts
  • non-Error throws in local .sts code
  • several hazard-prone reflection and meta-programming surfaces

See Banned and Restricted Surface for the grouped reference list.

Assignment and mutability rules are tighter

soundscript treats some assignability relations as unsound even if TypeScript accepts them. You see that most clearly around:

  • mutable variance
  • callable parameter extraction
  • aliasing that invalidates earlier narrowing
  • widening to unrelated structural targets that do not preserve runtime meaning

These rules are there because .sts is trying to be sound, not just a little stricter than TypeScript.

See also