A Rust-flavored superset of TypeScript that compiles to plain TypeScript.
The point is not Rust cosplay — it is safety, enforced at the compiler
level. Rust-like syntax is the vehicle; the goal is that entire classes of
bugs (unhandled variants, unchecked errors) become compile errors, while
Rust-style dev patterns (match, tagged enums, Result) stay ergonomic and
cheap to use. Every feature must earn its place by making something unsafe
inexpressible or loudly rejected — not by merely looking Rusty.
Vanilla TS flows through untouched. New Rust-style constructs (match, enums-with-data, expression if, Result) are parsed, semantically checked, and lowered to idiomatic TS — then tsc verifies the output. We never reimplement TypeScript's type system; we generate code for it and weaponize its checker as our backend verifier.
zts → TS → JS
Each step erases a layer: zts sugar erases into TS syntax + types; TS types erase into JS. What survives to runtime is only what was always real data (tagged objects).
Two sibling repos:
~/code/
swc_rustify/ ← fork of swc-project/swc (ProgrammingCheetah/swc_rustify).
Stage 1 lives here: extended AST (swc_ecma_ast) + extended
parser (swc_ecma_parser). zts changes go on a `zts` branch;
`main` stays clean tracking upstream.
zts/ ← this repo. The compiler driver: semantic pass, lowering
pass, emit, CLI. Path-depends on ../swc_rustify/crates/*.
zts-fmt-forks/ ← the zts-fmt engine (Phase 7): ZurNetwork forks of
dprint-plugin-typescript + deno_ast + dprint-swc-ext,
zts branches, repointed at ../swc_rustify. Print rules
for every zts node; 669 specs + 1144 idempotence fixed
points live THERE. crates/zts-fmt path-depends on the
plugin fork.
.zts file
→ extended parser (fork: knows match/enum/expr-if syntax)
→ semantic pass (zts: exhaustiveness setup, later: move checking)
→ lowering pass (zts: custom nodes → vanilla TS AST)
→ emit (stock swc_ecma_codegen, unmodified — lowering
happens BEFORE codegen, so codegen never sees
custom nodes)
→ plain .ts + sourcemap
→ tsc/svelte-check (their typechecker does the heavy lifting)
Spans from the original .zts source are preserved through lowering. They are the sourcemap story and the diagnostics story. Never synthesize spans when an original one exists.
- ✅ Milestone 0: identity compiler (round-trip confirmed).
- ✅ Fork cloned (
../swc_rustify),ztsbranch carrying the extensions,mainclean tracking upstream. - ✅ Phase 1:
matchvertical slice complete (see checklist below). Crate renamed tozestty;cargo testruns snapshot + tsc exit tests (needsnpm installfor the local tsc). - ✅ Phase 1 review gate: adversarial code review + security review (two
independent reviewers, two verification rounds each). Highlights baked in:
packrat memo + single-parse commit path (nested-match DoS), stacker-backed
parser recursion + semantic depth limit 2048 + leak-don't-drop for
over-deep ASTs, directive-prologue-safe helper injection, IIFE lowering
for narrowing, globalThis.Error keystone throw with
ztsTag. - ✅ Phase 2: toolchain. npm workspace under
packages/(@zestty/native,@zestty/vite-plugin,@zestty/svelte-preprocess), all with node:test suites;npm testruns them,npm run build:nativerebuilds the binding. - ✅ Phase 4: all four locked features (match, enums-with-data,
expression if,
@zestty/coreResult). See the checklist below. - ⬜ Phase 3 (DX: TextMate grammar, LSP proxy) — the remaining roadmap item.
Rust-style, compiler-enforced exhaustiveness.
// zts source
const area = match (shape) {
Circle { radius } => PI * radius ** 2,
Square { side } => side ** 2,
};Lowers to:
// generated TS (universal absurd, issue #47: the ONE shared helper is
// imported from @zestty/core — injected once per module, after
// directives+imports. `--inline-preamble` (CLI/zts-check) and
// `inlinePreamble` (vite/svelte options) restore the standalone
// per-module declaration for consumers without the core dependency;
// virtual twins that never ship — plain zts-check, the language-server —
// stay inline so dep-less workspaces keep working.)
import { __ztsAbsurd } from "@zestty/core";
const area = ((__m) => {
const __k = __m.kind;
if (__k === "Circle") {
const { radius } = __m;
return PI * radius ** 2;
}
if (__k === "Square") {
const { side } = __m;
return side ** 2;
}
return __ztsAbsurd(__k);
})(shape);__ztsAbsurd(x: never): never is the keystone: if an arm is missing, __k
does not narrow to never and tsc rejects the generated code, naming the
missing variant. TypeScript's own checker proves exhaustiveness — zts just
aims it. The reported error span must map back to the original match in the
.zts file.
Load-bearing details, learned the hard way (review-gated, two rounds):
- It must be an IIFE, not a named helper call: TS contextually types
IIFE parameters from call arguments AND preserves outer
letnarrowing only through IIFEs. The discriminant is the argument, soawait/yield/thisinside it keep working. - Testing the alias
__kstill narrows__m(TS 4.4 aliased discriminant narrowing), and passing__k— not__m— to the keystone works for both union and single-variant types. - The helper throws via
globalThis.Error(real Error: stack, instanceof) with the unmatched tag onztsTag— never a bareErrorreference (hygiene is not TS-type-aware; bare global refs rename user shadows and silently change type meaning), and never akindfield (the thrown object must not impersonate a domain tagged union).
Arm patterns (Phase 5): besides Variant { bindings }, arms take
string/number/bigint/boolean/null literals ("active" =>, 404 =>,
-1 =>, 1n =>, true =>, null =>) and a _ wildcard. undefined is
NOT a pattern (it gets a dedicated diagnostic); 0 and -0 are the same
arm (they are === in JS). A match is either variant-mode or literal-mode, never
mixed; _ is legal in both but must be the single LAST arm and carries no
binding. Literal mode drops the __k alias — arms test __m === <lit> and
the keystone receives __m itself (equality narrowing runs __m to never
when exhaustive; a missing literal is a TS2345 naming it, no --strict
required). A _ arm replaces the return __ztsAbsurd(...) tail: it is
the explicit, greppable opt-out of the exhaustiveness keystone.
Parser note: match is a contextual keyword. str.match(re) must keep
working. On match (expr) {, checkpoint (ParserCheckpoint), attempt a
match-expression parse, backtrack to a call expression on failure.
A library feature, not syntax. Ships as a tiny runtime package
(@zestty/core):
type Result<T, E> = { kind: "Ok"; value: T } | { kind: "Err"; error: E };Plus Ok(), Err() constructors and combinators: map, map_err,
and_then, is_ok/is_err guards, unwrap/unwrap_or. Must use the same
kind discriminant convention as everything else so it composes with
match and ?. Zero fork changes.
Results are plain tagged objects — combinators are FREE FUNCTIONS, never
methods, so a Result survives JSON/structuredClone/network boundaries. For
Rust-style left-to-right chaining, ResultPipe (approved by Zuri,
2026-08-06) wraps them ephemerally:
const out = ResultPipe(parsePort(raw))
.map((p) => p + 1)
.map_err((e) => `boot failed: ${e}`)
.done(); // plain Result back out — the pipe itself is never stored/sentRust-style enums, lowered to tagged objects + factory functions:
// zts source
enum Shape {
Circle { radius: number },
Square { side: number },
}Lowers to a discriminated union type + constructors:
// generated TS (fields readonly by default since 0.4.0 — Phase 7's
// breaking change; `mut field: T` opts out per field, `kind` never can)
type Shape =
| { readonly kind: "Circle"; readonly radius: number }
| { readonly kind: "Square"; readonly side: number };
const Shape = {
Circle: (radius: number): Shape => ({ kind: "Circle", radius }),
Square: (side: number): Shape => ({ kind: "Square", side }),
};Never emit TypeScript enum. Not ever. Tagged unions only.
Readonly payloads (Phase 7, 0.4.0 — BREAKING). Writing a payload
field is a TS2540 unless the field is declared mut (Cell { mut count: number }); kind is always readonly with no opt-out (a kind write
would let a value lie about its own variant). Migration is mechanical —
one TS2540 per mutation site, fix = add mut. mut is contextual: a
field literally named mut keeps working (mut: number, and
mut mut: number is a mutable field named mut). Honest limits
(recorded): TS readonly is shallow (an array-typed field's contents
stay mutable) and not part of structural assignability — the guarantee
fires on direct writes through the typed view, where the aliased-
mutation bug class actually lives.
Note: enum in zts source shadows TS's enum keyword — this is a deliberate
semantic replacement, the one place zts is not a strict superset. TS enum
syntax should be a hard error with a friendly diagnostic.
Blocks are expressions; a block's value is its tail expression.
// zts source
const a = if (b === 0) { 3 } else { 4 };Lowering: simple branches → ternary; multi-statement branches → IIFE (same
machinery as match). Applies to if and match arm bodies. if used as an
expression without else is a compile error.
Distinct identities over the same underlying type — the ID-confusion bug class becomes a compile error.
// zts source
newtype AccountId = string;
// generated TS
type AccountId = (string) & { readonly __ztsNewtype: "AccountId" };
const AccountId = (__ztsValue: string): AccountId => __ztsValue as AccountId;The parens around the underlying type are load-bearing: & binds tighter
than |, and stock codegen has no type-level fixer — unwrapped, a union
underlying type would brand only its last member. The factory parameter is
__ztsValue (locked __zts prefix): a bare value would be captured by
typeof value underlying types.
Known limitation (recorded, open decision for the Engineer): the brand is
the newtype's NAME, so two newtype Id = string declarations in different
scopes/modules are the same type to tsc — and the brand is structurally
forgeable without a cast (Object.assign("raw", { __ztsNewtype: "AccountId" as const }) type-checks). Options if this bites: qualify the
brand with a module discriminator, or a unique-symbol brand. Until decided,
same-named newtypes share identity.
The brand property exists only at the type level; the factory is an identity
cast, so newtypes are zero runtime cost. Two newtypes over the same
underlying type are mutually unassignable, and the raw type is not assignable
to either (both are TS2345, no --strict needed). The underlying type flows
the other way — a Meters is still a number, so arithmetic keeps working.
Rules: newtype is a contextual keyword committing on exactly the
type-alias rule (same-line identifier follows), so newtype stays a valid
variable name everywhere else. No type parameters in v1. declare newtype is
a hard error (declare the lowered shape instead). Lowering runs pre-resolver
next to enums: one decl becomes two (type + const, legal declaration
merging), hoisted past the directive prologue/imports with the same
order-independence enums get, export preserved on both halves.
Postfix ? propagates Err with an early return — unchecked errors stay a
compile error, checked ones stop needing ceremony.
// zts source
function boot(raw: string): Result<number, string> {
const port = parsePort(raw)?;
return Ok(port + 1);
}
// generated TS (statement-level hoist)
function boot(raw: string): Result<number, string> {
const __t = parsePort(raw);
if (__t.kind === "Err") {
return __t;
}
const port = __t.value;
return Ok(port + 1);
}tsc enforces the contract on the generated shape: .kind on a non-Result
is TS2339, and return __t fails (TS2322) unless the enclosing return type
accepts the Err side. For that check to be REAL, the enclosing function
must carry an explicit return type annotation — zts requires it (in a
void-contextual callback like xs.forEach(x => ...) TypeScript accepts any
returned value, so an inferred return type would let the Err vanish
silently). ? is also banned in generators (the early return would become
TReturn) and setters (cannot return a value); both get dedicated
diagnostics. Boundary: an annotation of any (or a return type absorbing
the Err some other way) satisfies the rule syntactically but voids the
check — any is outside every zts guarantee, not just this one.
Parse rule (locked): ? is a try operator ONLY where a ternary is
impossible — immediately before ; ) , ]. One-token lookahead, fully
deterministic; ?. stays optional chaining, ?? stays nullish coalescing,
optional parameters ((a, b?) => …) keep winning for bare identifiers. The
operand is the whole conditional-level expression (a + f()? tries
a + f()).
v1 statement-shape lock (review this before widening): ? must be the
WHOLE right-hand side of a const/let declaration, a return argument,
or a bare expression statement, inside a real function body. Nested uses
(g(f()?), [f()?]) are semantic errors — hoisting them would silently
reorder side effects (g(a(), f()?) would run f before a). Also banned:
module top level (nothing to return from) and match-arm / if-expression
blocks (they lower to IIFEs, which would hijack the early return; a nested
real function with an annotated return type resets the rule). In
single-statement slots (if (c) g()?;,
loop bodies, labels) the expansion is wrapped in a block, so the early
return keeps its meaning.
A literal-union type WITH a runtime side: the values list and the
membership guard that plain type aliases can't give you.
// zts source
union DeleteOutcome = 'soft' | 'hard' | 'unknown';
// generated TS
type DeleteOutcome = 'soft' | 'hard' | 'unknown';
const DeleteOutcome = {
values: ['soft', 'hard', 'unknown'] as const,
has: (__ztsRaw: string): __ztsRaw is DeleteOutcome =>
DeleteOutcome.values.indexOf(__ztsRaw as DeleteOutcome) !== -1,
};The wire-normalizer stops hand-listing literals — DeleteOutcome.has(raw)
narrows, so has(raw) ? raw : 'unknown' is the whole R8 fail-closed
mapping — while the closed side keeps exhaustive match (a missing member
is a TS2345 naming it). Members are string literals only in v1 (numbers
would widen the guard's parameter type); duplicates are a compile error;
leading | allowed; same contextual-keyword commit rule, hoisting, and
export behavior as newtype. The guard uses indexOf (ES5-clean —
includes would raise the emitted-TS lib floor to ES2016), and its cast
rides on the argument — a receiver cast would need parens the fixer strips
(load-bearing).
TS-flavored trait impls: ONE new construct, everything else is plain TypeScript on both ends.
// zts source — the trait is a vanilla TS interface (zero zts grammar)
interface Display<Self> {
fmt(self: Self): string;
}
enum Shape {
Circle { r: number },
}
impl Display for Shape {
fmt(self): string {
return match (self) { Circle { r } => `circle r=${r}` };
}
}
// generated TS — methods merge into the factory const
const Shape = {
Circle: (r: number): Shape => ({ kind: "Circle", r }),
fmt(self: Shape): string { /* lowered match */ },
} satisfies { [key: string]: unknown } & Display<Shape>;Calls are plain TS: Shape.fmt(s) (static-method style), and generics
take the dictionary as an ordinary parameter — describe(s, Shape) works
because the factory structurally satisfies Display<Shape>; there is
no call-site lowering at all. Vanilla .ts consumers inherit the
whole system as objects + interfaces.
Traits v2 (Phase 7 — SHIPPED):
impl From<string>, From<number> for Id {
from(value: string | number): Id { // no self: associated fn
return typeof value === "string" ? Id.Str(value) : Id.Num(value);
}
}
// → Id.from("x") / Id.from(4), and
// satisfies { ... } & From<Id, string> & From<Id, number>- Associated functions:
selfis optional — without it the method merges asId.from(...)with fully user-annotated params. Bare firstselfstill marks a receiver method (annotated in the lowering); ANNOTATED self and non-first self stay errors. - Trait type-args:
impl From<string>→ Self is the FIRST type argument, header args appended after:satisfies From<Id, string>. - Comma-header multi-instantiation: each listed trait is a separate
satisfies obligation over ONE union-typed body (TS overload semantics
via intersection — the signature-level guarantee; the body's own
dispatch is the author's, deliberately NOT
From<string | number>which is a weaker single claim). - Early semantic checks (original spans, before tsc): unknown trait
name at the header (declared/imported in-module, module level);
method-vs-variant collisions; CROSS-impl duplicate methods ("
xis defined by bothHumanandMachine") — superseding the v1 "left to tsc" coherence disposition; same-file no-extendsinterfaces get a syntactic member-NAME comparison (missing/extra methods) — imported traits still defer tosatisfies, names from syntax only, never types.
Load-bearing details:
fnDROPPED (Zuri, 2026-08-07 — lands in 0.4.0, breaking): impl members are bare TS-style methods,fmt(self): string { ... }— exactly class/object-method syntax. An impl block only contains methods, sofnwas never structurally necessary; v1 (0.3.1) shipped with it, 0.4.0 removes it (a member starting with the wordfngets a dedicated migration diagnostic). Return types are TS-style:(no->token exists). The bareselfreceiver is required first and gets its type annotation in the LOWERING (annotating it in source is an error).- The
{ [key: string]: unknown }intersection member absorbs the variant factories fromsatisfies' excess-property check. Written inline, neverRecord— a user shadow ofRecordwould silently change what conformance means (same reasoning as the globalThis rule). - Orphan rule (semantic):
impl X for Trequires zts enumTin the SAME statement list; v1 is enum-impls only.export implis an error (the factory const owns the export). Single-statement slots (if (c) impl ...) are an error. - Safety, all tsc-enforced (exit-tested): non-conforming method → TS2322 on the method span; colliding methods across impls → TS2300 duplicate identifier; non-exhaustive match inside a method → the existing keystone.
- Within-impl duplicate methods and
__zts-prefixed method names are semantic errors (better spans than the tsc equivalents).
Traits are the one adopted feature whose Rust home is inside the type checker, so every extension request meets the same wall: if resolving a call requires reading a type, it is out — permanently. The type plane is write-only (Conventions). This table is the lookup answer to every future "can traits do X":
| Inside (syntactic — shipped or Phase 7) | Outside (type-directed — never) |
|---|---|
impl blocks, factory merge, auto-satisfies |
x.fmt() method-call syntax |
receiver methods; associated functions (no self) |
merging separate impl bodies under one name (needs call-site types or runtime type tests that erased types don't have — and we cannot even detect the safe primitive subset without reading types) |
trait type-args (impl From<string> for Status) |
blanket impls (impl<T: Display> Show for T) |
comma-header multi-instantiation (one union-typed body, satisfies A & B — TS overload semantics) |
specialization |
dictionary passing (describe(s, Shape)) |
implicit dictionary selection |
| orphan rule, coherence, early semantic checks | — |
Where Rust spends a type checker, zts spends a method name (e.g.
from_string/from_number instead of two merged From impls). That is
the trade the whole language is built on.
newtype UserId = string;
newtype OrderId = string;
constrict UserId != OrderId; // brands really are distinct
constrict keyof Config == "host" | "port"; // shape pinned against drift
constrict ApiResult extends Result<User, ApiError>;Lowers to erased type aliases whose generic constraint fails when the claim is false — a TS2344 at the assert's own line:
import type { __ztsExpect, __ztsEqual, __ztsNot } from "@zestty/core";
type __ztsConstrict0 = __ztsExpect<__ztsNot<__ztsEqual<UserId, OrderId>>>;Operators: == (EXACT equality via the conditional-fn identity trick —
distinguishes brands, any vs unknown, optionality; mutual extends
would not), !=, extends (one-way assignability). Load-bearing
details: alias names self-uniquify with a counter (hygiene is not
TS-type-aware and will not rename duplicate TYPE aliases); the LHS
parses as a NON-conditional type or A extends B would be swallowed as
a conditional-type head; inline mode (--inline-preamble / scripts)
emits the three helper aliases locally with the probe fn-types
explicitly parenthesized (no type-level fixer exists — the newtype
parens lesson). Renamed from static_assert with parens dropped
(recorded in Phase 7 item 2: the paren form is legal TS and would steal
meaning). Contextual: const constrict = 1 and constrict(x) stay
vanilla; commit = same-line word.
Option<T>, let/let mut, no-untracked-throws, move checking.
(Newtypes and ? shipped in Phase 5 — see features 5 and 6 above;
traits shipped in Phase 6 — feature 8.)
Shipped 2026-08-06 (Zuri-approved): not as a prefix operator —
pure sugar, not <unary-expr> → !expr, same precedence as ! (so
not a === b is (!a) === b). Rationale: !expr is visually easy to
skip when reading, unlike ||/&&; a loud negation keyword reduces
misread-logic bugs. Disambiguation is a deterministic one-token rule, no
speculation: negation only when the operand token can never legally
follow an identifier (a word or literal, on the same line). Everything
else keeps vanilla meaning: not(x) calls, not.foo, not => x,
not instanceof F, and ASI (not⏎x is two statements).
RE-DECIDED (Zuri, 2026-08-07 — lands in 0.4.0, breaking): not is a
RESERVED WORD. The contextual rule above is retired: not can no
longer be a user identifier (const not = 1, not(x) as a call,
not.foo become errors). Two reasons: (a) the formatter cannot
round-trip not today — the parser desugars it to ! at parse time
with no AST marker, so zts-fmt would silently rewrite not ready to
!ready; reserving the word lets the parser keep a real ZtsNot node
(appended Expr variant) that the compiler lowers to ! in lower.rs
like every other zts construct and the formatter prints verbatim;
(b) it removes the ambiguity carve-outs entirely.
Considered and REJECTED (Zuri, 2026-08-05): paren-less if conditions.
Statement if must stay vanilla TS (superset promise), and the ) {
boundary is load-bearing for the ASI guards; Rust only affords this by
banning struct literals in conditions. Not worth re-opening that ambiguity
class for cosmetics.
These are on the horizon but nothing gets built until Phase 2 below is green.
Type-plane first. Every guarantee ZesTTY adds must live in the types
of the generated TS wherever possible — enforced by tsc (the gate we
already trust) and erased by emit (the discipline we already follow).
Runtime code in a lowering is justified only when a value must exist at
runtime anyway (enum factories, Result objects). If a guarantee can
neither be expressed as emitted types nor piggyback on values that
already exist, it needs a checker we'd have to write ourselves — and
that is an automatic reject.
The type plane is write-only for us. The compiler is purely syntactic (parse → lower → emit; no inference, no checker), so a feature must be decidable from syntax alone at lowering time:
- Lowers to pure types? Best case — zero runtime cost, zero hygiene risk, fully erased.
- Needs runtime values that would exist anyway? Acceptable — this
is
match/enums/Resulttoday: the shape is runtime, the guarantee is still the type plane. - Needs branching on a type, or analysis tsc cannot be shaped into? Reject — the same reasoning that killed no-untracked-throws.
We can still pose questions to the type plane: emit types that encode
a proof obligation and let tsc be the oracle — its failure surfaces as
our diagnostic (the __ztsAbsurd keystone is exactly this), and TS's
own type-level operators (keyof, mapped, conditional types) are
computation we may emit without ever reading the answer. Corollaries in
shipped code: match picks literal-mode vs variant-mode from the arm
shapes, never from the matched expression's type; a bindingless
variant arm emits no destructure at all (issue #38) — lowering decides
shape from syntax, tsc owns meaning.
- Discriminant field is
kind(string literal). Everywhere. Non-negotiable. - Generated helper identifiers use the
__ztsprefix (__m,__ztsAbsurd). Use SWC syntax contexts (hygiene) so generated names cannot collide with user code. - New AST nodes are never handled in codegen. Codegen arms for custom
nodes are
unreachable!("must be lowered before emit"). If codegen panics, the lowering pass has a bug. - Fork discipline: all fork changes on the
ztsbranch. When adding a node, use the donor-node technique: pick a structurally similar existing node (CondExprforMatchExpr),grep -rn '\bCondExpr\b'acrossswc_ecma_ast,swc_ecma_visit,swc_ecma_codegen, and mirror every registration site. Compile errors are the checklist. - Snapshot tests (
instacrate) from day one:.ztsin → generated TS out. Every feature lands with snapshots covering the happy path and the should-fail-under-tsc path.
- AST:
MatchExpr { span, discriminant, arms },MatchArm { span, pattern, body }(pattern-based since Phase 5:MatchPat::Variant/Lit/Wildcard),Expr::Matchvariant (fork) - Regenerate/extend
swc_ecma_visitfor the new nodes (fork,cargo test -p generate-code test_ecmascript) - Parser: contextual
match, checkpoint/backtrack,str.match(re)survives (fork) - Lowering pass: match → IIFE + if-chain +
__ztsAbsurd(zts, plus resolver+hygiene for__ztsname collisions) - Emit via stock codegen, original spans preserved (zts)
- CLI:
zestty file.zts→file.ts(+.ts.map) (zts) - Exit test:
tests/tsc_exit.rs— deleting an arm makes tsc emit TS2345 on the generated TS, and the error position maps through the sourcemap back to the originalmatch
Phase 1 scope notes (locked by Zuri): arms are strictly Variant { bindings } => expr —
no bare variants, no guards. (Wildcard _ and literal arms were added in Phase 5 with
Zuri's approval; the rest of the lock stands.) await/yield directly in an
arm body is a compile error until arms can lower to async IIFEs.
- napi-rs binding (
crates/zestty-napi→@zestty/native; each compile on a 64 MiB-stack thread so deep input can't SIGABRT the host; linux-x64 build vianpm run build:native) - Vite plugin (
@zestty/vite-plugin:transformfilters.zts/.ztsx, native zts→TS, then vite'stransformWithEsbuildTS→JS withinMapso the composed map reaches back to the.zts) - Svelte preprocessor (
@zestty/svelte-preprocess:<script lang="zts">→ compiled TS +lang="ts"attribute rewrite, chain beforevitePreprocess) - Sourcemap proof — headless form: plugin test asserts a position in
the final JS maps through both stages back to the originating
.ztsmatch arm. (Manual browser-devtools breakpoint check still worth one eyeball pass in a real app.)
- Where to declare the Svelte preprocessor depends on where your Kit
options live. If you pass options inline to
sveltekit({...})invite.config.ts, SvelteKit IGNORESsvelte.config.jsentirely (it warns about this) — so the preprocessor (andmoduleExtensions) must go inline there too. The better setup: move everything tosvelte.config.js, which external tools (svelte-check, editors) read anyway. Pick ONE home for the options; a preprocessor declared in the file Kit isn't reading cost a real debugging round downstream. - svelte-check cannot type-check
lang="zts"blocks itself (v4.6): it checks the ORIGINAL source and keys the language off the originallangattribute.zts-checkcloses the gap completely: it checks.ztsmodules via twins AND runs svelte-check over a shadow tree where each zts component carries its compiled script aslang="ts"— so TEMPLATE bindings against zts script members are fully type-checked, with diagnostics remapped to original positions. Putzts-checkin CI next to your build;--no-svelteskips the component pass if you need to. - Go-to-definition from consumers lands in the
.zts, not the twin (issue #45). In committed-twins mode tsserver answers definition queries with the generated.ts;typescript-zestty-plugin(a TS Language Service plugin, same pattern astypescript-svelte-plugin) intercepts them and remaps the span to the sibling.ztsthrough the.ts.mapthatzts-check --twinsnow emits next to each twin (whole-word symbol search when a twin has no map). Enable it per repo viatsconfig.json→compilerOptions.plugins: [{ "name": "typescript-zestty-plugin" }](plusnpm i -D typescript-zestty-plugin); the VS Code extension bundles it automatically. The committed.ts.mapis machine-independent (sibling-relativesources, nosourcesContent) and inert to staleness checks and orphan scans.
Each repeats the Phase 1 loop (fork tests, snapshots, tsc exit test, review gate).
-
_wildcard match arm:_ => expr, LAST arm only, at most one, no binding — an explicit, greppable opt-out of the exhaustiveness keystone (the lowering replaces__ztsAbsurdwith the wildcard body). -
matchon literal unions:match (status) { "active" => ..., 404 => ..., true => ... }— string/number/boolean literal arms (negative numbers via-1 => ...), tsc proves exhaustiveness via the same never-narrowing. A match is either variant-mode or literal-mode, never mixed (_legal in both). Lowering shape (load-bearing): literal mode drops theconst __k = __m.kindalias entirely — arms test__m === <lit>and the keystone receives__mitself, because (a) non-object discriminants have no.kindand (b) equality narrowing eliminates each tested literal from__m's union, so an exhaustive literal match narrows__mtoneverand a missing arm makes tsc name the missing literal (TS2345, no--strictneeded). - Newtypes:
newtype AccountId = string;→ branded type (string & { readonly __ztsNewtype: "AccountId" }) + factory. Kills the ID-confusion bug class; contextual parse mirrors thetype-alias commit rule (same-line ident follows). See feature 5. -
?try operator: postfix?propagatesErrwith an early return; tsc enforces error-type compatibility against the enclosing return type (no checker work on our side). Constraints (locked):?fires only where a ternary is impossible (before;),]) —?.belongs to optional chaining, sof()?.gchaining is unavailable (bind first); banned inside match arms / if-expression blocks in v1 (IIFE boundary would hijack the early return). Shipped with an extra v1 statement-shape lock — whole RHS ofconst/let/return/expr statement only, nested uses would silently reorder side effects. See feature 6.
Dispositions from the same review (recorded so they are not re-litigated):
Option<T>— DEFERRED. Zuri's position:nullandundefinedshould not exist as separate concepts; until that unification design exists, a naked Option fightsT | undefinedidiom. Revisit with?-operator interop and boundary adapters.- Match guards (
ifin arms) — DEFERRED, design doc first: guards cannot count toward exhaustiveness (tsc cannot reason about predicates), so they need Rust's discipline (guarded arms do not discharge a variant). let/let mut— REJECTED: changing what vanillaletmeans breaks the superset promise; the itch belongs to a zts-check lint.- no-untracked-throws — REJECTED: needs call-graph analysis tsc cannot be
shaped into; the ZesTTY answer to exceptions is
Result+?. - Traits — promoted to Phase 6 (Zuri-approved, 2026-08-06); move checking stays a horizon item.
The next implementation. Repeats the Phase 1 loop (fork parser tests, snapshots happy + error, tsc exit test per safety property, review gate).
- Traits — TS-flavored, one new construct only — SHIPPED, see
feature 8. Design decided
in conversation with Zuri (2026-08-06); passes the type-plane rule
at levels 1–2. Deliberately NOT Rust cosplay: everything except
the
implblock is plain TypeScript on both ends.- Trait declaration = a vanilla TS
interfacewith aSelftype parameter (interface Display<Self> { fmt(self: Self): string }). Zero new grammar; erased. impl Display for Shape { fn fmt(self): string { ... } }— (return types are TS-style:— there is no->token in the lexer, and the TS-flavored direction prefers it anyway) — the one zts construct (forheader locked: it reads as a sentence). Lowers to methods merged into the factory const the enum already emits, withsatisfies ... & Display<Shape>appended — the dictionary is a value that must exist anyway (level 2), and conformance is tsc's verdict (level 1).- Calls are plain TS: direct
Shape.fmt(s)(static-method style); generic bounds are ordinary dictionary parameters —function describe<T>(x: T, impl: Display<T>)called asdescribe(s, Shape), because the factory namespace structurally satisfiesDisplay<Shape>. No call-site lowering exists at all (a Rust-style turbofish would have required cross-module knowledge of callee bounds — non-syntactic, rejected). Vanilla.tsconsumers get the whole system for free; it is just objects and interfaces. - Safety properties (each gets a tsc exit test): missing/wrong
method →
satisfies Display<Shape>fails on the impl; deleted impl → every call site fails; non-exhaustivematchinside an impl → the existing__ztsAbsurdkeystone fires; two impls colliding on a method name → duplicate identifier (TS2300) — coherence enforced by the emit shape, never checked by us. - Orphan rule (locked):
impl ... for Tonly in the module that declaresT— locally checkable at parse time, and what makes the factory-merge lowering possible. - v1 scope forks (open): enum-only impls vs also newtypes/plain types; default trait methods (lean: defer to v2); multi-trait impls on one type (falls out of the merge — keep).
- Trait declaration = a vanilla TS
- Universal absurd (issue #47) — SHIPPED. The default emit now
imports the ONE shared
__ztsAbsurdfrom@zestty/core(as--twinsmode already did, issue #37) instead of declaring the helper per module. Rationale (Zuri): the per-module copy was re-generated on top of functions — a waste of memory. Opt-outs: CLI--inline-preamble,inlinePreambleoption on the vite plugin and svelte preprocessor (both now carry an optional peer on@zestty/core >= 0.4.0). Virtual twins that never ship stay inline deliberately: plainzts-checktemp twins and the language-server, so dep-less workspaces keep working and editor diagnostics can't invent a missing-module error. Scripts (non-modules) always inline — they cannot import. Pinned by an inline-mode snapshot + a self-contained tsc exit test.
Each language item repeats the Phase 1 loop (fork parser tests, snapshots happy + error, tsc exit test per safety property, review gate). Pre-1.0 semver: the 0.x minor slot carries breaking changes — item 1 is THE headline break of this release, loudly documented.
Language:
- 1. Readonly enum payloads +
mutopt-out — SHIPPED (issue #54), BREAKING. Variant fields emitreadonlyin the generated tagged union;mut field: Topts out per field;kindis always readonly with no opt-out. Migration is mechanical: every break is a TS2540 at the exact mutation site, fix = addmut. Lives on zts-owned constructs only, so the superset promise is untouched (unlike the rejectedlet/let mut). Honest limits (recorded): TSreadonlyis shallow, and readonly is not part of structural assignability — the guarantee fires on direct writes through the typed view. - 2.
constrict A == B;— SHIPPED, see feature 9 (renamed fromstatic_assert, parens DROPPED — both re-decided with Zuri 2026-08-07: the paren formstatic_assert(a == b)is already legal TS — a call with a comparison — so it would steal meaning from valid programs, and no speculation rule can distinguish the two since both parse. Paren-free commits on the same-line-ident rule likeunion, and two identifiers in a row is never valid TS). Erased type-level assertion; operators==(mutual, Equal-trick),!=,extends. Lowers to a type alias whose constraint fails when the claim is false (TS2344 remapped to the assert line).Equal/Expecthelper types ship as type-only exports from @zestty/core. - 3. Non-empty array sugar
T[+]— SHIPPED. Lowers to[T, ...T[]](post-resolver type rewrite;ZtsNonEmptyArrayappended to TsType — a real node, not a parse-time desugar, so zts-fmt round-trips it). Callers must prove non-emptiness;xs[0]isTeven under noUncheckedIndexedAccess (exit-tested both directions: [] and plain T[] are TS2345).isNonEmptyguard shipped in @zestty/core (a.lengthcheck does not narrow — recorded). Read-shape contract (.pop()does not un-narrow), like all TS tuples. Suffix composes:string[+][],readonly T[+]. - 4. Traits v2 — SHIPPED, see feature 8. Associated functions (methods without
self→ merge asStatus.from(...); params are user-annotated so the receiver-typing step is skipped); trait type-arguments in the header (impl From<string> for Status→satisfies From<Status, string>, Self first then header args in order); comma-header multi-instantiation (impl From<string>, From<number> for Statuswith ONE union-typed body — each listed trait is a separate satisfies obligation; deliberately NOTFrom<string | number>, a weaker single claim); early semantic checks with original spans (trait ident must be declared/imported in-module; method-vs-variant and cross-impl collisions named "xis defined by both A and B"; same-file no-extends trait interfaces get syntactic member-name comparison — imported traits still defer tosatisfies; NOTE this supersedes the v1 "collisions left to tsc by design" disposition, re-decided with Zuri 2026-08-06). See "the permanent boundary" table in feature 8 for everything deliberately NOT here. - 4b. Syntax re-decisions, 2026-08-07 — SHIPPED (issue #60), both
breaking: drop
fnfrom impl blocks — members are bare TS-style methods (fmt(self): string {}; the wordfnstarting a member gets a migration diagnostic); reservenotand keep it as aZtsNotAST node lowered in lower.rs (formatter round-trip — see thenotsection above;const not = 1/not(x)calls become errors). - 5. Impls for newtypes and unions — SHIPPED. The orphan rule
widens to all three zts nominal types. Union factories merge like
enums (methods + satisfies;
values/hasname collisions are a semantic error at the original span). Newtype factories are ARROWS, so impls attach viaglobalThis.Object.assign(factory, { methods } satisfies ...)— Object.assign's return type is the intersection, keeping the const callable AND carrying the methods; dictionary passing (describe(u, UserId)) works unchanged. No syntax change — the impl grammar was already target-agnostic (grammar/formatter dispositions: none needed).
zts-fmt (no formatter can parse zts; prettier-plugin route rejected —
bidirectional TS↔zts nesting makes embed delegation impractical):
-
6. DONE (verdict GREEN, then productionized into the three forks). Feasibility spike (the risk gate): fork dprint-plugin-typescript (Rust, prettier-style output, built on swc's AST), repoint its swc deps at
../swc_rustify@ztspath-deps, confirm the version pin aligns and a zts-flag parse flows through its pipeline. A day to learn what a month would otherwise cost. -
7. DONE, all 19 zts nodes. Print rules for the zts nodes (match, enums-with-data, expression-if chains, newtype, union, impl/fn, not, postfix
?) — donor-node discipline, mirroring existing dprint patterns. -
8. DONE: 21 zts spec files / 143 specs; 1144 idempotence fixed points (4 configs, 2 starting points, triple-pass). Idempotence suite over the existing fixtures; comment preservation.
-
9. DONE: crates/zts-fmt (bin
zts-fmt [--check], zts/ztsx only, line width 80) +format()on @zestty/native, served through @zestty/language-servertextDocument/formattingso VS Code and nvim get format-on-save with zero new editor wiring; defaults tuned to this repo's prettier style. CONFIG (issue #70, post-0.4.0): a prettier-shaped subset —printWidth(default 80),useTabs,singleQuote, plus thesortImportsopt-in — from azts-fmt.jsondiscovered upward from each formatted file; unknown keys are errors (fail-closed, like zts-check), deliberately NOT.prettierrc(.ztsis excluded from prettier in consumers; half-sharing a file invites drift). All three surfaces share the resolution: CLI flags (--print-width/--use-tabs/--single-quote/--sort-imports) and napiformat(source, filename, options?)overlay the discovered file; the LS gets it purely via discovery (editors can't pass flags), and LSP tabSize/insertSpaces are deliberately ignored so CLI and editor emit stay identical. RULING (Zuri, 2026-08-08): canonical emit never reorders imports/exports or named specifiers — prettier's posture; side-effect import order makes sorting a semantic hazard — dprint's sort is thesortImportsopt-in. -
10. AMENDED + DONE: fixtures are deliberately NOT format-gated — several encode load-bearing layout (the ASI regression fixture REQUIRES
match(1)+ newline + block) and reformatting them would destroy what they test. The gate is instead the fork's 1144 idempotence fixed points plus crates/zts-fmt's smoke corpus (canonical constructs format, are idempotent, and every construct round-trips). CI clones the three forks as siblings. -
11. Release 0.4.0.
Open inputs from Zuri before the affected items start: RESOLVED — the
impl-block indent reset was nvim (tree-sitter indent override, issue
#57, shipped in this phase). (fn: DROPPED; static_assert: renamed
constrict, paren-free; not: reserved — all decided 2026-08-07, see
item 4b and the feature sections.)
Standing rule (Zuri, 2026-08-07 — also in CLAUDE.md): every syntax change ships WITH its VS Code + nvim highlighting updates and its zts-fmt print-rule updates in ONE patch-version commit.
Ships as 0.4.x PATCHES ("keep it at 0.4 for now — none of these break"), each its own patch with the normal PR flow and tests. Order is load-bearing — no optimization lands without a before/after number from the harness:
- 1. Benchmark harness — SHIPPED (issue #67).
npm run bench(bench/): compile-time suite over the green fixtures + a deterministic synthetic large module + a tiny file (pure per-call overhead) through @zestty/native, LS didChange→publishDiagnostics keystroke latency + completion latency over real stdio LSP, and zts-check wall-clock over a synthetic project. JSON results,--baselinediff mode,--smokegate in npm test; the v0.4.0 baseline is committed at bench/baselines/v0.4.0.json. Every later item cites its numbers — headline finding: keystroke latency ~105ms on a 3k-line module vs ~8ms for the raw compile of the same text (the LS overhead, not the compiler, is the target), while per-call native overhead is ~0.12ms. - 2. Toolchain optimization round — napi reusable sized worker thread (replaces the per-call 64 MiB spawn; preserves the stack-safety property), LS incremental/debounced recompile, zts-check content-hash skip-cache, watch-mode per-file twin regen.
- 3. LSP hover granularity — sharper position mapping in match arms + synthesized hovers for zts-only constructs, answered from the enum decl.
- 4. LS semantic tokens — real nvim highlighting; retires the parity-rule nvim caveat (update the CLAUDE.md parity note when it lands — confirm with Zuri then).
Disposition (Zuri): toolchain speed first; generated-output optimizations explicitly deferred — do not re-litigate emitted shapes without profiling evidence. Parked: prebuilds/marketplace publishing.
Design round complete (issue #73 has the full measured record; all claims verified against tsc 5.9.3/6.0.3/7.0.2). Scope:
- 1. Range patterns in match — SHIPPED (#76):
400..=499 =>— inclusive only (exclusive..REJECTED: making..a token steals4..toString(), a superset break — recorded likenot/constrict); integer literal bounds (± ok,lo <= hi, no bigint/string v1 — bigint gets a dedicated "not supported" diagnostic); width cap 1024 enforced in semantic.rs BEFORE lowering allocates (a DoS control: an unbounded range is ~2·10⁹ AST nodes = LS OOM on a keystroke); range arms are literal-mode arms (mixing with variant arms stays the existing error — today it would be a silent no-op, verified). LOWERING (the decisive design, "shape 5" of the round): the arm becomesif (__ztsInRange<__ztsRangeN>(__m, lo, hi))where__ztsRangeN = lo | lo+1 | … | hiis a hoisted ERASED type alias and__ztsInRangeis a@zestty/coretype predicate (__v is T, parameterunknownso mixed unions work, integer-gated via% 1 === 0— ES5-clean,Number.isIntegerwould raise the lib floor). A predicate narrows WITHOUT comparing, which is why this shape lives where every other died: relational comparisons narrow nothing (all three checkers); switch-fallthrough/===-expansion narrows but tsc rejects every expanded literal not in the union (TS2678/TS2367) and syntax alone can't know which those are; type-level Enumerate hits TS2589 at absolute bound ~1000 (width-10Enumerate<4000,4010>fails). Type-plane cost ≈ zero to width 10,000 (measured). EXHAUSTIVENESS RULE (from syntax alone): a range arm changes nothing about the tail — no_means the absurd tail, exactly as today; the compiler NEVER decides whether_is required, tsc does (closed numeric literal union → keystone discharges; opennumber→ TS2345 names it and the author adds_). NEW COMPILE-ERROR CLASS (ships with the feature): syntactic reachability — overlapping/covered range and literal arms are semantic errors via integer-interval merging (tsc is silent on covered range arms, verified); extends the existing duplicate-literal-arm loop. Fork discipline:..=is a NEW TOKEN — Token is repr(u8) with ordinal-range classifiers, so "compile errors are the checklist" FAILS for tokens: insert next to DotDotDot, register before_expr/starts_expr EXPLICITLY (no fallthrough), and build crates/swc_ecma_lexer too (second token type, broken twice before). Lexer:read_numberdeclines the trailing.only when the next two bytes are.=(so4..toString()survives);read_token_dotgrows a DotDotEq branch (handles spaced400 ..= 499). Speculation window: zero widening needed — arm parsing is outside the rewind path. The pattern is a REAL AST node (MatchPat::Range— thenotlesson), with print rule + TextMate scope + nvim disposition per the parity rule. - 2. Numeric and mixed union members — SHIPPED (#76):
union HttpStatus = 200 | 404 | 500(and mixed string/number) —has(__ztsRaw: number | string)guard per member set; the enabling companion: the range-pattern safety payload only materializes over closed numeric literal unions, which zts cannot declare today (feature 7 is string-only). Composition measured end-to-end in the round (wire guard → exhaustive ranged match). - 3. Release 0.5.0.
HONEST SAFETY STORY (recorded, unembellished): range patterns do not
eliminate a runtime bug class; they remove the main practical reason
authors reach for _ — which disables the keystone for the ENTIRE
match — and they add the overlap/unreachable-arm compile errors.
Remaining shelf (unassigned): trait default methods, Option/null-unification (likely 1.0-territory breaking), bigint ranges.
- TextMate grammar for syntax highlighting
- LSP proxy: run tsserver over generated TS, map diagnostics back through sourcemaps (Civet's approach)
-
zts-check(issue #3): the CI twin of the LSP proxy — compile all.ztssources +lang="zts"blocks into a shadow tree, run tsc/svelte-check there, remap diagnostics through the sourcemaps back to the.ztsorigins. The sourcemap discipline was built for exactly this.
- Enums-with-data (feature #3): zts
enumgrammar in the fork (parse_any_enum_decldispatch; TS member syntax /const enum/declare enumget friendly errors), lowered pre-resolver to a tagged union type alias + typed factory const.kindis a reserved field name. - Expression
if(feature #4): mandatoryelse, else-if chains, blocks-as-expressions ({ stmts; tail }). Statement-free chains lower to ternaries (await stays legal); chains with statements lower to an IIFE (await/yield rejected with a diagnostic). Match arm bodies accept the block form too (=> {is a block, like arrow bodies — object literals need parens). -
@zestty/corewithResult,map,map_err(feature #2): plusis_ok/is_errguards andunwrap/unwrap_or, all on thekindconvention. Exit test proves Result + expression-if + match compose and the keystone still fires when theErrarm is deleted. - Then and only then: revisit the deferred list
Discipline rule: nothing from Phase 4 ships before Phase 2 is green. Language projects die with five features parsed and zero usable in an editor.
MAX_EXPR_DEPTH = 2048 assumes ≥8 MiB of stack for the compiler's recursive
passes in debug builds. Shipping shapes are covered (napi binding: 64 MiB
thread; CLI: default main-thread stack), but a small-stack host embedding
zestty as a library and feeding it a legitimate ~2000-deep expression could
still abort. Options if this ever matters: lower the constant, or run
compile() on a sized thread like the napi binding does. Pre-existing since
Phase 1 (twice-gated there); flagged again by the Phase 4 verification round.
- You are extending a working identity compiler, not starting from scratch. Run it first; keep the round-trip green at all times.
- The fork at
../swc_rustifyis part of this project. Edits there are expected and correct — but only on theztsbranch (create it offmainif it doesn't exist yet), only mirroring existing patterns. - Do not add language features beyond the four above. Do not "improve" the scope. Ergonomic polish within a feature is welcome; new features are not.
- Do not reimplement any part of TypeScript's type checker. If a guarantee can be obtained by shaping the generated TS so tsc enforces it, that is always the right design.
- Preserve original spans through every transformation. Sourcemaps and diagnostics both depend on it.
- Prior art for technique questions: Civet (superset → TS, LSP-over-sourcemaps), Borgo (Rust-flavored syntax → simpler host language).