sound-ts
Type soundness for TypeScript, inspired by F# types. Not a Microsoft/TypeScript fork.
What this is / isn’t
| Is | Isn’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)
-
brand typestring-literal brands — member literals stay assignable; a phantom arm keeps two brands with the same members distinct. Outbound tostringworks; outbound to the bare literal union does not -
brand typerefined brands — phantomunique symbolemit; bare base not assignable under stocktsc -
validate type— same phantom +.is/.from -
cast<Target>(expr)— checked entry path (primitive checks or companion.from) -
On
.sts: rejectas/any/!/ wide types, no bare structural aliases, methods emit as readonly function properties (#36–#38, #40–#42)
1.0 slice (delivered on .sts) — AST +
symbols close more holes before emit. Ordinary .ts stays
stock TypeScript. Matrix:
SOUNDNESS_1_0.md.
-
FFI: author
x is Brand/asserts x is Brandrejected — entry viacast<>/.from -
Honest Mode B
isshapes (empty / ignored param / always-truerejected) - Mutation of companions / dialect-annotated bindings rejected
-
Conservative generics:
Partial/Requiredon brands rejected;Readonlyallowed -
Still deferred (see design note): proving
iscorrectness, unannotated mutation, full variance, ambient FFI -
Outbound widen from a
validate typeto the naked field structure remains a stocktschole until a later design closes it
Gate: new dialect surface should close a soundness hole, not paper over one. See also Non-goals.
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:
string|number|boolean|null- optional
? - arrays of primitives (
string[]) — notnull[] - unions of those (e.g.
string | null,string | number,string[] | number) - nested objects whose leaves are those shapes
Date- a
brand typeorvalidate typename from the same file, an import, or the batch — enter a raw value with that companion's.from; an existing brand value may be stored as-is
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
stsnever emits.js-
Default output →
<cwd>/.sound-ts/(gitignored; input outside the cwd caches next to that input) or-oyou choose - Stock Nest /
tscstill ownsdist/ - Don’t point
tscat raw.sts -
Whole
.stsbatch on eachstsrun for cross-file|/&andcastcompanions -
Enter refined /
validate typevalues via.fromorcast<>— bare bases are not assignable under stocktsc
0.x (delivered) — tooling + surface soundness on
.sts. Checkboxes stay checked as history. Ordinary
.ts stays stock TypeScript.
Seamless integration — roadmap v1:
-
Shadow emit
—
stswrites a gitignored cache, never a sibling.ts. CI fails if that output is committed. -
Bidirectional resolve
—
.tsand.stsimport each other. Import paths stay as the author wrote them. -
Drop-in build script
— one
package.jsonscript expands, then runstsc. Expand failure stops the build. -
Loader
— Vite expands
.stson dev and build, so you stop callingstsby hand.
Soundness on .sts —
roadmap v2:
-
Reject
as— a type assertion in.stsfails expand.as constandcast<>stay.asis not rewritten intocast. -
Reject
any—anyin.stsfails expand.unknownstays.anyis not rewritten tounknown. -
No structural aliases
— a bare object alias in
.stsfails expand.validate typestays nominal. Outbound widen to the naked structure stays a stocktschole. -
Method parameters
— a method in
.stsemits as a readonly function property, so stockstrictFunctionTypeschecks parameters contravariantly. -
Reject
!—value!andprop!: Typein.stsfail expand.!==stays.!is not deleted. -
Reject
Object,{},Function— those types in.stsfail expand. An empty object literal stays. They are not rewritten tounknown.
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.
- AST frontend with parity — TypeScript parser/AST replaces the regex frontend; fixtures expand equivalently.
-
Dialect AST nodes
—
brand type/validate type/cast<>are explicit nodes (or a stable side-table). - AST soundness visitors — move 0.x bans off masked-string scans onto AST visitors.
- Scopes and symbols — cross-file Sound-TS symbols for brands / companions / cast targets.
- Complete soundness rules — 1.0 reject/accept matrix (boundaries, predicates, mutation, generics subset) on the AST + symbol layer.
-
.fromis the only door — unknown input enters with.from(always checks). Known members are the literals ("admin",0). No unchecked constructor. Formatting and labels stay normal functions.
Caveats
-
Parser edges:
regex literals; nested
${}in templates (whole template skipped). -
Editor: author in
.ststo avoid stocktscred squiggles on the dialect keywords. -
Cross-file companions:
pass every
.stson the CLI batch (e.g. a directory) so|/&members andcasttargets resolve against the project companion map. After expand, stocktscsees normal imports of the emittedtype+ companion.
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):
- Clone
mishelashala/sound-ts -
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).
- No Microsoft / TypeScript fork to maintain
- No custom TypeScript language server in 0.x — stock
tscruns on expand output; 1.0 adds a Sound-TS semantic phase before emit, still not a Microsoft fork - No open brands without
is(use refined brands with a custom.is) - Refined brands and
validate typeare nominally opaque under stocktsc(phantom unique-symbol brands) — not structural aliases of their bases. Enter via.from/cast<>. - String literal brands are the member literals or a phantom arm (nominal under stock
tsc; member literals still assign). They do not flow back to the bare literal union. Number/bigint literal brands not supported yet. - 0.x delivered surface bans and tooling (roadmap v1 / v2). Outbound widen from a
validate typeto the naked structure remains a stocktschole until a later design closes it. - 1.0 roadmap issues #50–#54 delivered (AST + symbols + initial rule slice). Deferred items: SOUNDNESS_1_0.md. Package version may still be
0.xuntil a1.0.0release cut. - Not a full schema library —
validate typeaccepts nested objects,Date, and brand / validate field names. Generics (Partial,Required), function fields, and formatting inside.fromstay out. - VS Code extension not on Marketplace yet (local install only)
defineLiteralSetis not the authoring surface
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):
-
Runtime validation from types (auto
.is/ assert)
This dialect covers it viavalidate type— see Validate type.validate type User = { id: string; age: number }; User.is(data); // ✅ companion User.from(data); // throws on mismatch -
Nominal / opaque / brand types with runtime companions
That’s whatbrand 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. -
Contracts / checked casts
This dialect covers it viacast<>— 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.
| Token | Value | Role |
|---|---|---|
--color-brand | #0066FC | CTAs / logo mid (large/UI) |
--color-signal | #00D9FB | Focus / logo highlight |
--color-bg | #0B0F1A | Page background |
--color-text | #F4F7FB | Body text |
--color-text-muted | #9AA8C7 | Secondary text |
--color-border | #243049 | Borders / dividers |
--color-error | #F04438 | Fail-closed examples only |