sound-ts

sound-ts

Type soundness for TypeScript, inspired by F# types. Not a Microsoft/TypeScript fork.

Playground

What this is / isn’t

IsIsn’t
A small dialect: brand type, validate type, and cast<> checked casts A fork of TypeScript or tsc
A CLI transform → plain TS + runtime companions A custom checker / full language server
Stock tooling on generated output Library-first defineLiteralSet as the product API

Soundness (project goal)

TypeScript’s contract is type safety with erased types — no runtime companions from the type layer. Sound-TS aims for F#-style soundness: opaque / branded (phantom) types plus runtime companions so values enter through checked paths.

Where we claim soundness today (0.x)

1.0 slice (delivered on .sts) — AST + symbols close more holes before emit. Ordinary .ts stays stock TypeScript. Matrix: SOUNDNESS_1_0.md.

Gate: new dialect surface should close a soundness hole, not paper over one. See also Non-goals.

Authoring — string literal brands

Prefer .sts files. Stock TypeScript language service will red-squiggle brand type inside ordinary .ts / .tsx. The VS Code TextMate grammar covers .sts; injection into .ts only helps highlighting, not the checker.

String literal brands emit as the member literals or a phantom arm, plus a runtime companion (.values / .is / .from / .toPrimitive). setRole("admin") stays OK. A second brand with the same members is not assignable. A value of the brand assigns to string, not back to the bare literal union.

Unknown input enters with .from. .from always runs the check and throws on failure. A known member is the literal itself (setRole("admin"), type: 0). The companion does not format and has no method that skips is. toFixed and slugify stay in a normal function that calls .from. A label stays a normal function over .toPrimitive.

// file: roles.sts
brand type Account = "admin" | "regular";

const admin: Account = "admin";

function greet(role: Account): string {
  return role === "admin" ? "hello, admin" : "hello";
}

greet(Account.from("admin"));
Account.from("guest"); // throws

brand type CurrencyNumber = string {
  is(value: string): value is CurrencyNumber {
    return /^\d+\.\d{2}$/.test(value);
  }
}

function currencyFromAmount(amount: number): CurrencyNumber {
  return CurrencyNumber.from(amount.toFixed(2));
}

For plain TS / JS after expand, use the playground.

Brand enum

A closed number set with a name for each value. Same checks as a number literal brand. The name is a property on the companion. from, is, values, name, and toPrimitive cannot be member names.

brand enum AccountCode {
  Zero = 0,
  One = 1,
  Two = 2,
  Three = 3,
}

const code: AccountCode = AccountCode.Zero;
AccountCode.from(1);
AccountCode.from(4); // throws

0 is the same member as AccountCode.Zero. Runnable copy: examples/tsc-app/src/account-code.sts.

Refined brands

Custom is body; CLI generates from that validates via is. Emit is plain .ts — a phantom unique-symbol brand plus companion — so stock tsc blocks bare number (or other base) from assigning. Enter via .from / cast<PositiveInt>(…); runtime checks still matter at boundaries.

// file: positive.sts
brand type PositiveInt = number {
  is(n: number): n is PositiveInt {
    return Number.isInteger(n) && n > 0;
  }
}

See the playground for the expanded TypeScript / JavaScript.

Brand-only unions / intersections

Members must be known brand names from the transform input — same file or another .sts on the CLI batch. Open types like string in a brand | / & are rejected; unknown brands and cycles error at transform time.

Two-file example (declare in a.sts, compose in b.sts):

// a.sts
brand type Admin = "admin";
brand type Regular = "regular";

export { Admin, Regular };
// b.sts
import { Admin, Regular } from "./a.js";

brand type Staff = Admin | Regular;

Run sts on the directory (or both files) so the project brand map sees every declaration, then point stock tsc at the expanded output.

Call-site usage of the runtime companions:

Staff.is("admin");     // true (Admin member)
Staff.from(raw);       // throws if neither brand matches

Expanded TS / JS lives in the playground.

Same-file intersections still work:

brand type User = "u";
brand type Session = "s";
brand type Authed = User & Session;
// Authed.is(value) → User.is(value) && Session.is(value)

validate type

Object types with primitive fields expand to a phantom unique-symbol brand plus the same .is / .from companion shape as refined brands. Stock tsc blocks bare object literals — enter via .from / cast<User>(…).

// file: user.sts
validate type User = { id: string; age: number };

User.is(data);   // boolean
User.from(data); // throws on mismatch; returns User

See the playground for the expanded TypeScript / JavaScript.

Supported field shapes:

Generics (Partial, Required, Array<…>), function types, and other shapes error at expand and name the gap. .from checks the value and does not format it. null[] stays rejected. Not a full schema library.

validate type Flags = {
  active?: boolean;
  tags: string[];
  id: string | number;
  note: string | null;
};

Checked casts — cast<Target>(expr)

Throw-on-mismatch cast. Targets are primitives or known companions (brand type / validate type) from the CLI batch.

const n = cast<number>(raw);
// → inline typeof check; throws if not a number

const u = cast<User>(raw);
// → User.from(raw) when User is in the project companion map

Unknown targets error at transform time. Pass the whole .sts batch so cross-file cast<User>(…) resolves.

CLI quickstart

Install from npm (scoped packages under @mishelashala):

npm i -g @mishelashala/sound-ts-cli
# or
pnpm add -g @mishelashala/sound-ts-cli

sts ./example.sts -o ./out/example.ts
# same binary: sound-ts

Library: @mishelashala/sound-ts-core. Bins: sts / sound-ts. Point bundlers at the transformed output.

From source (contributors):

git clone https://github.com/mishelashala/sound-ts.git
cd sound-ts
pnpm install && pnpm build && pnpm test
node packages/cli/dist/cli.js ./example.sts -o ./out/example.ts

Adoption / roadmap

Keep most of the app as normal .ts (stock tsc / Vite). Add .sts only where you want brand type, validate type, or cast. VS Code already highlights .sts, so “just add a file” isn’t red-squiggle hell.

Today — two-step compilation (expand, then stock build):

.sts  --sts -o …-->  plain .ts  --tsc/nest-->  .js in normal build out

0.x (delivered) — tooling + surface soundness on .sts. Checkboxes stay checked as history. Ordinary .ts stays stock TypeScript.

Seamless integration — roadmap v1:

Soundness on .sts — roadmap v2:

1.0.0 — complete soundness — roadmap 1.0. AST frontend, dialect nodes, AST bans, symbols, and the initial 1.0 rule slice are delivered. Stock tsc stays the backend on expand output. Deferred holes live in SOUNDNESS_1_0.md.

Caveats

VS Code (thin, local-only)

packages/vscode registers .sts, highlights brand type (including in .ts), and can shell out to the CLI. No full custom checker.

Local (Cursor / VS Code):

  1. Clone mishelashala/sound-ts
  2. Extensions → … → Install from Location… → pick packages/vscode

Optional one-liner for VSIX:

cd packages/vscode && npx @vscode/vsce package --no-dependencies

then Install from VSIX…

Transform command needs pnpm build so CLI dist exists. Marketplace not listed yet (extension is local-only; CLI/core are on npm).

defineLiteralSet?

Internal emit / runtime helper — not the public API. Authors write brand type; the CLI expands. Do not treat import { defineLiteralSet } as the product.

Non-goals (this cut)

See also Soundness (where we claim soundness vs what stays open toward 1.0).

FAQ: Why not TypeScript?

TypeScript’s design non-goal: don’t add RTTI or emit different JS from the type system — no runtime overhead from the type layer. Stock tsc therefore won’t grow companions from the type layer.

Gaps that non-goal leaves where this dialect claims a surface (not a wishlist of everything TS declined):

  1. Runtime validation from types (auto .is / assert)
    This dialect covers it via validate type — see Validate type.
    validate type User = { id: string; age: number };
    User.is(data);   // ✅ companion
    User.from(data); // throws on mismatch
  2. Nominal / opaque / brand types with runtime companions
    That’s what brand type + companions cover here — string-literal brands keep member-literal assignability and a phantom arm; refined / validate are phantom-opaque. Enter untrusted values via .from / cast<>. See Refined brands / String literal brands.
  3. Contracts / checked casts
    This dialect covers it via cast<> — see Checked casts.
    const n = cast<number>(raw); // ✅ throws on mismatch
    const u = cast<User>(raw);   // ✅ User.from when companion is in the batch

Honest limits of the dialect live under Non-goals (no fork, no custom checker, brand does not flow back to the bare literal union, …) — not as a catalog of TypeScript declines we also don’t ship. TS already emits for some expression features (enum, namespace, parameter properties, downleveled class/async, decorators) — those aren’t “types that generate companions.”

Bottom line: for erase-the-type and emit a runtime artifact, stock TS usually says no — sts fills only the dialect surface above (brand type, validate type, cast<>, phantom emit).

Pages palette

UX lock for this site. Brand blue is large/UI only on dark (≈3.9:1) — body copy uses text tokens. Signal is focus / logo highlight only. Error is fail-closed examples only.

TokenValueRole
--color-brand#0066FCCTAs / logo mid (large/UI)
--color-signal#00D9FBFocus / logo highlight
--color-bg#0B0F1APage background
--color-text#F4F7FBBody text
--color-text-muted#9AA8C7Secondary text
--color-border#243049Borders / dividers
--color-error#F04438Fail-closed examples only