Design Principles & Invariants
Foundational, non-negotiable invariants for the tjs-lang language stack. Every feature and tool must preserve these. A violation is a bug, not a feature gap — fix the tool, don't ask the user to work around it.
The type system is not the source of truth. Reality is.
A type system is documentation, autocomplete and lint assistance. It is never evidence about what a program does. If the type system says A and the running program does B, the type system is wrong — not the program. TJS is the attempt to take that seriously rather than merely admit it.
TypeScript inverts this in practice, not by design but by erasure: its
annotations make claims that are never checked against anything, so a wrong
annotation is indistinguishable from a right one until a consumer's runtime
disagrees. as, !, any and a stale .d.ts are all the same failure — a
claim outranking the thing it describes.
A TJS type is an example: a real value that exists at runtime. In
function greet(name: 'Alice'), 'Alice' is a real string: it says name takes a
string, and it is a value the function can be called with. An example is not a
claim about a value, it is a value, so there is nothing for it to be wrong
about. That is the whole design, and everything below follows from it.
Why the drift is structural, not carelessness
There is a second and stronger reason the annotation loses, and it holds even for a type system with perfect checking and no erasure:
TypeScript's types are declarative. The code they describe is imperative. So a programmer documenting what imperative code does is forced to re-express it in a different language — one with different semantics, different expressiveness and different failure modes — and then keep two artifacts in two paradigms agreeing by hand. That translation will tend to fail. Not through carelessness: it is a hand translation between paradigms, performed continuously, by someone whose actual job is the other one.
Which is why the code is the more trustworthy artifact. The code is the thing. The types are a second description of it, written in a foreign language, maintained manually.
The tell is what happens when a constraint gets hard. Expressing it drags you
into conditional types, mapped types and template-literal types — a
Turing-complete metalanguage with no debugger, no runtime, and no way to
execute a case and look at it. You cannot console.log a type. The tools for
testing types are themselves written in the type language, so a bug in your
description and a bug in your description-of-the-description are the same
activity. At that point the annotation is a program nobody can run, asserting
things about a program everybody runs.
The sharper form: the incentive, not the entropy
TypeScript is the set of lies you tell
tscto get your code to lint.
Saying the translation "tends to fail" is too kind, because it sounds like decay. It does not drift randomly — it drifts in a direction, and the direction is whatever makes the checker stop complaining. At the moment of writing an annotation the programmer's objective is not "describe this accurately", it is "clear the error." Those are different goals and only one of them is being optimized.
It is a familiar defect: a gate that alters the thing it measures, the way
teaching to the test changes what a test score means. A type checker is a gate,
and the annotations are authored to satisfy it. as, any, !, @ts-ignore, and an over-broad type that
"works" are not failures of the system — they are its equilibrium.
You cannot lie to a worked example. There is no as that makes add(2, 3)
equal 0. An example is not a claim you can weaken until it passes; it holds or
it does not, and the checker is the program itself. This does not compile:
function add(a: 2, b: 3): 0 {
return a + b
}
TJS calls add(2, 3) while transpiling, gets 5, and stops with
Transpile-time test failures, reporting Expected 0 at 'add', got 5. The only
way to make it compile is to make the example true:
function add(a: 2, b: 3): 5 {
return a + b
}
console.log(add(2, 3)) // 5
So the difference is not that TJS has no escape hatches — it is that ours are
confessions. !, :!, DangerousLegacyEquals, LegacyDefault, LegacyDate:
each is named, greppable, and deliberately ugly (see "Make
stupid stuff stand out" below). as Foo looks like ordinary code, which is
precisely what makes it dangerous — the lie and the truth are visually
indistinguishable, so nothing accumulates as evidence and no reviewer can grep
for the debt.
The uncomfortable corollary, and it is the right behaviour: fromTS preserves
the lies rather than laundering them. A TypeScript return type becomes a :!
return, which declares the type and tests nothing:
TypeScript: function name(id: number): string { … }
TJS: function name(id: number):! string { … }
: string names a set, not a worked example, and inventing one would be
manufacturing a truth we do not have. (Parameter types carry over as bare type
names, such as id: number, which are checked at runtime.) Converted code is
therefore honest about how much of it is still assertion — which is the point of the conversion contract below, where TJS
is equivalent-or-better and tells you where to improve rather than pretending
the improvement already happened.
TJS's answer is to delete the translation, not to improve it. A type is written in the language it describes:
- An example is a value.
function greet(name: 'Alice')—'Alice'is JavaScript, evaluated by JavaScript, present at runtime. There is no second language and therefore no translation step to drift. - A predicate is code. A
predicate(x) { return x > 0 }inside aTypedeclaration (see Declarations) is imperative code describing imperative code, in the same paradigm, and it runs, and you can debug it, and it can have tests.
That is also the honest reading of the type-system north star (last section):
JSON-Schema is the declarative half and cannot express computation, so
$predicate is not an extension bolted on — it is the imperative half, without
which the same translation problem reappears inside our own type system.
It adjudicates. That is what makes it a principle rather than a mood.
When two mechanisms disagree, this rule says which one is the bug — and it already decides three live cases that were each carrying their own local rule:
- A signature example that does not hold is an error, not a type pattern.
function add(a: 2, b: 3): 0must fail, becauseadd(2,3)is 5. It is perennially tempting to "fix" that into a type match; the temptation is exactly the inversion this principle forbids.:!is the way to say "I have only a claim here, not a worked example". - Where the inline runtime and the full runtime disagree, the inline one is
right. Emitted JavaScript carries a small built-in copy of
Typeand its relatives, and calls that copy even when the full runtime is loaded. Not because the copy is better, but because it is what runs, so it is what gets fixed.docs/type-identity.mdmeasures four such disagreements. - A measured disagreement is not permitted behaviour. It is a fact about the system. Recording it as allowed does not make it stop being one; it just reserves slack for a future regression to occupy silently.
Applied to our own documentation
The same rule binds what we say about the language. docs/tjs-vs-typescript.md
is generated from data that is executed — every row runs against tsc --strict and against TJS (src/lang/differences.ts,
src/lang/differences.test.ts), so a documented claim cannot drift from the
language without a test going red. A comparison table is the most inviting place
in a project for a false claim to live undisturbed, which is why that one is
data rather than prose.
The direction it points
Probe reality, then derive the contract — never the reverse. This is what
docs/ambient-contracts.md is for. TypeScript types an event's e.target as a
bare EventTarget, so e.target.value does not type-check without a cast, even
when the element is plainly an input. That pessimism is a claim, and a measured, verified predicate over the real object is a fact. When
the two conflict the annotation loses, and the useful artifact is the one you can
execute.
An on-ramp and an off-ramp. No craters.
TJS provides two sanctioned paths and should not accumulate a third kind of thing.
- The on-ramp —
fromTS/dialect: 'js'(see Dialect resolution, below). Converted source keeps JavaScript semantics wholesale, marked by the annotationfromTSwrites at the top of the file,/* tjs <- input.ts */. You are not in TJS yet, and the file says so. - The off-ramp — graduation (
graduate()). It removes the annotation, so the file opts into every TJS rule at once, and applies the rewrites that are only correct once it is gone.
Both are file-level and total. A file is either playing by JS rules or by TJS rules, and which one is legible at a glance.
A crater is a third thing: a per-construct hole in an otherwise-native file — a way to keep one legacy behaviour without admitting the file is not ready. Craters are individually reasonable and collectively corrosive. Each one adds permanent surface area, and each one relieves exactly the pressure that would otherwise get the file graduated properly.
The precedent is the one JavaScript itself set: strict mode and ESM removed legacy behaviour
without a trapdoor. There is no unstrict block, no /* @allow-with */, no way back to
with or to sloppy-mode arguments aliasing. "use strict" still permits var and
eval — so the lesson is not which constructs were removed, it is that where something was
removed, it was removed outright. A language that takes something away and then hands you a
way back has not taken it away; it has added a feature and a caveat.
The consequence, and the one honest exception
Prefer refusing a construct to providing an escape for it. var has let/const; eval()
has Eval(); native .tjs refuses both outright (plain JavaScript keeps them, through the
on-ramp), and neither loses functionality that the on-ramp does
not already cover — if you truly need the legacy behaviour, you are describing a file that
should not have graduated yet.
The named-confession family (DangerousLegacyEquals, LegacyDefault, LegacyDate, …) is
the exception, and a confession earns its place by passing both of two tests:
- The functionality is genuinely irreplaceable. Not "inconvenient to do otherwise" —
impossible.
varfails this:let/constcover it, minus the hoisting footgun it is banned for. - Someone actually needs it. Irreplaceability alone is not enough, and this is the test that does the real work.
eval is the case that shows why both are required. It passes the first — direct eval is a
syntactic form that reads the CALLER's scope, and no wrapper can reproduce that, so the
capability is strictly unavailable once refused. It is refused anyway, because nothing uses it:
this repository's own .tjs source contains no call to it. An irreplaceable capability nobody reaches
for is not a capability, it is surface area.
LegacyDate passes both. Native TJS refuses new Date() and offers Timestamp (epoch
milliseconds) instead; LegacyDate() is how you get a real Date when an API demands one. A
Date INSTANCE cannot be produced any other way — Timestamp is a number, and an API that wants a Date wants the object — and real source needs it. So it
stays, and it is not on a deletion schedule. It is the honest exception rather than a
transition.
Naming still matters independently of that. The old unsafe prefix gated three unrelated
things behind one vague, now deprecated marker (unsafe var, unsafe eval(…), unsafe new Date(…)) and
could not be removed in one step, because "what breaks if this goes?" had no answer. Naming
each part made each individually greppable, individually measurable and therefore individually
decidable: var and eval came to be refused, new Date became LegacyDate, and
unsafe itself is deprecated.
Language subset relationships
JavaScript ⊆ TJS in the js dialect (dialect: 'js', TjsCompat, converted TypeScript)
AJS ⊆ TJS
Native .tjs is not a further superset. It is a stricter dialect of the same
language: it refuses var, eval() and new Date(), and changes what == and
truthiness mean. That is why the dialect, not the language, is what a file opts
into.
Two hard invariants:
TJS ⊇ JavaScript. In the js dialect, every legal JavaScript program is legal TJS with the same meaning. TJS adds capability (types-as-examples, runtime contracts, monadic errors) but never removes the ability to transpile valid JS, and never silently changes its semantics. TJS's footgun-removal rules (statement termination, honest equality, honest truthiness, …) do change behaviour — so they are gated on the dialect (below), not applied to plain JS.
.tjsis a better language, not just JS — choosing it (the.tjsextension,dialect: 'tjs', or a bare string, which defaults to native TJS) is the opt-in to those rules. Plain JS reaches semantics-preserving transpile viadialect: 'js', or the bareTjsCompatdirective at the top of a file.TjsCompatrestores JavaScript's semantics (==coerces again, and so on) but does not switch off validation: a parameter example is still checked at runtime.dialect: 'js'turns both off.TJS ⊇ AJS. Every legal AJS source is legal TJS source.
ajs(src)andtjs(src)may do different and more with the same string — TJS may enforce:contracts, run signature tests, etc. — but TJS must never reject source that AJS accepts.
(TypeScript → TJS is a conversion, not a subset relation, and is explicitly out of scope for these invariants.)
The operative rule: "more, never illegal"
A subset relationship lets the superset do more with the same code, but never makes subset-legal code illegal (a hard parse/transpile error).
So when a richer TJS feature evaluates subset code:
- A check that runs and fails (e.g. a function's example output ≠ its declared example) → a hard error is fine. The code is genuinely wrong.
- A check that cannot run (references TJS can't resolve at build time, a test harness that can't execute the module, etc.) → inconclusive, never a hard error. Inconclusive ≠ failed.
Worked example: signature tests
TJS runs a function's signature example as a build-time test (call with the parameter examples, assert the result matches the return example). It must obey the rule above:
- Self-contained, executable, self-consistent function → test runs; mismatch is a hard error. ✅
- Function uses a name TJS can't resolve at build time — an AJS atom
(
httpFetch,llm,store), or an imported binding it actually calls → the test is inconclusive, recorded as such in the transpile result'stestResults, and never a transpile error.
Several top-level functions in one file each get their own test, and all of them
run, as do exported ones; an import the function does not use costs nothing.
Only using an unresolvable name makes a test inconclusive.
Two shapes get no signature test at all, which is a coverage gap rather than an inconclusive result: class methods and arrow-function consts. Worth knowing before relying on signature tests as a guarantee, and tracked on the Virta board.
Without this, AJS agents (which call atoms and may declare return types) and multi-function helper sources would be illegal TJS — breaking invariant 2.
Dialect resolution (how invariant 1 is operationalized)
The unit of opt-in is the dialect, and tjs() takes it explicitly:
| input | TJS rules | meaning |
|---|---|---|
tjs(src, { dialect: 'js' }) |
OFF | plain JS — semantics preserved |
tjs(src, { dialect: 'tjs' }) |
ON | native TJS |
tjs(src) (bare string) |
ON (default) | native TJS (back-compat default) |
fromTS(src) then tjs(...) |
OFF | TypeScript → TJS → JS |
For file-based tooling (CLIs, bundler plugins, module loaders, doc systems),
the dialect is the file extension. There is one canonical mapping — exported as
dialectForFilename / sourceKindForFilename from tjs-lang/lang — that all
tools must share so .js is never silently "improved" into different semantics:
.js/.mjs/.cjs→dialect: 'js'(preserve; TJS still reserves the right to add better diagnostics, never to change behaviour).tjs→ native TJS (the better language).ts/.mts/.cts→fromTS(TS → TJS → JS)
A bare string defaults to native TJS only because it has no extension to consult;
any tool that knows the source's origin should pass dialect (or use the helper).
A tool that accepts all three kinds of source routes .ts through fromTS,
imported from its own subpath (tjs-lang/lang/from-ts), and everything else
through tjs(). There is deliberately no one-call helper in tjs-lang/lang:
the TypeScript path needs the TypeScript compiler, and folding it in would put
the compiler in every consumer's bundle.
Why this matters
- AJS portability. AJS agents ("code travels to data": sandboxed,
atom-calling) must carry into the full TJS toolchain — editor support,
.d.ts, docs — with zero rewrites. - JS adoption. A developer must be able to drop existing
.jsinto TJS and have it transpile, then add contracts incrementally. - Guardable. These are testable properties: keep a fixture of representative
AJS/JS snippets asserted to
tjs()(and the JS to options-off TJS) without throwing, so a regression is caught immediately.
When a lower layer takes on a duty, the layers above stop paying for it
When one layer guarantees something completely, the layers above it should be re-optimized for something else. Paying for the same guarantee twice means one of the two payments is the bug.
AJS loops are the clearest case. Starlark (the configuration language of the Bazel
build tool, built never to hang) keeps for over finite iterables and
bans while, because Starlark's termination guarantee lives in the syntax: only
the bounded form can be allowed. AJS's guarantee lives in fuel instead —
every evaluation step is charged, and execution stops when the budget is gone
(S1/S4 in ASSUMPTIONS.md). So AJS can keep both the bounded
loop (for…of) and the unbounded one (while), and choose its constructs for
legibility alone. It bans C-style for (;;) on that ground, not for safety: three
clauses of mutable-counter bookkeeping in one header is what small models get
wrong. Every AJS construct choice may ignore termination and ask only "what does
a small language model write correctly?" — a question with a measured answer
(experiments/agent-legibility/).
The same shape recurs elsewhere: the capability membrane absorbs host-reference safety, so atom authors write ordinary data-returning functions; verified predicates absorb purity, so predicate authors write ordinary JavaScript.
Make stupid stuff stand out, and look ugly and clumsy
The safe path gets the short, pretty spelling. The dangerous path is deliberately awkward. Ergonomics is a safety mechanism: what a language makes easy is what will be written, so the shape of the syntax is incentive design whether or not anyone intended it.
dangerouslySetInnerHTML is the canonical example — nobody types it by accident, nobody
skims past it in review, and its ugliness is doing real work.
Applied here:
| task | the good thing | the escape | what the escape gives up |
|---|---|---|---|
| compare two values | a == b |
DangerousLegacyEquals(a, b) |
no type coercion (0 == '' is true) |
| a point in time | Timestamp.now() |
LegacyDate() |
an immutable number, for a mutable object |
| declare a function | function f(x: 0) |
function f(! x: 0) |
every runtime check of its parameters |
Each escape is longer, noisier, and harder to skim past than the thing it replaces. That is not an accident to be tidied up later.
Two consequences, both load-bearing:
- Never optimize the ergonomics of an escape hatch. A short alias (
Leq,@unsafe) makes the dangerous path as cheap as the safe one and quietly deletes the entire mechanism. Requests to add one are requests to remove the safety. - If the dangerous thing is easier to write than the safe thing, the design is wrong —
and the fix belongs in the safe path, not in a warning. This is why
Timestamp.now()became a drop-in forDate.now()instead of us writing a better diagnostic: the correct response to "the unsafe way is more convenient" is to make the safe way convenient.
The principle also says when NOT to add friction. An escape that is merely unfamiliar rather than dangerous is just an obstacle — the ugliness has to be proportional to the risk, or it becomes noise people learn to ignore.
Conversion contract: TS → equivalent-or-better TJS, with guidance to improve further
Every conversion of TypeScript to TJS must produce code that behaves the same or strictly better, and must point at the improvement it did not make for you.
Three obligations, in priority order — a later one may never be bought with an earlier one:
- Equivalent. The converted file behaves identically to the TypeScript it came from.
Never silently change semantics. Where TJS reads a spelling differently, rewrite it to
preserve meaning rather than letting the extension change it. In TJS an example of
5means integer-only, while5.0means any number, son = 5(TypeScriptnumber) converts ton = 5.0, which accepts floats and still defaults to5. - Or better. Where TJS can be safer at no cost to behavior, take it — runtime validation from annotations TypeScript erases, real types on the boundary, footguns closed where the fix is provably meaning-preserving.
- With guidance to improve further. Where we could be better but can't prove the
rewrite safe, say so at the site, with a comment such as
// TJS: `= 5` narrows to an integer, `= +5` to unsigned. A gnarly coercion we can't rewrite becomes a visible marker, not a silent pass-through. This is the target:fromTSdoes not write these guidance comments yet.
Why this is a principle and not a feature. It is correct independently of any particular roadmap — it improves the TS → TJS path no matter what else is or isn't built — and it makes adoption a ladder instead of a leap. Combined with per-construct migration — convert the file, then mark only the individual sites that need the old behaviour (see "Named escapes" in TJS-SYNTAX.md) — it is the Crockford/JSLint dynamic: keep your code, be told what's bad and why, tighten one site at a time. Nobody rewrites a codebase to start.
It also decides questions that would otherwise be arguments. A spelling that TJS reads differently is not a breaking change to schedule; it is a conversion job, and the only remaining decision is rewrite-or-annotate. Obligation 1 is what makes obligations 2 and 3 safe to be ambitious about: you can afford to teach aggressively precisely because you have promised never to surprise.
Corollary — we do not erase TypeScript, we upgrade or annotate it. Removing a construct
is permitted only when it is genuinely a runtime no-op (as, satisfies, !, type-only
imports), and even then the removal is noted at the site, because the author wrote it and
a silent deletion discards intent the reader may need. Where the construct carries
information TJS can use, the correct treatment is an upgrade, not a deletion: m = {} as M
is a type annotation in disguise and should eventually become m: M = {}. Anything that is
not a no-op is never dropped — it is rewritten to preserve meaning, or flagged.
Corollary — we do not add runtime type checking to TypeScript that crashes it. Enforcing
annotations on converted TS would return errors where tsc-clean code ran fine; that is
obligation 1 violated, however well-intentioned. The only form of type checking that cannot
break the program it is inspecting is an observe mode: check, record, and return the
original value. TJS does not have one yet (every current setting changes what a failing call
returns), which is why TS-origin code has validation off by default. That is a design
position, not an oversight.
Enforcement (partial): obligation 1 is behavioural and therefore testable — run the
TypeScript and its conversion against the same inputs and require identical observable
results. bun run test:compat does this for six real projects by running each project's own
test suite against the converted code; there is no general per-file harness yet, so for
everything else this is a contract kept by hand.
The formal AST contract is AJS's, and syntax is subordinate to it
AJS's JSON AST is the specified artifact. TJS does not get one. AJS programs are stored
and sent as JSON syntax trees, so that format has to stay stable. TJS programs are not: TJS
rewrites its source to JavaScript text and parses that as JavaScript, and the ecosystem does
not need yet another JavaScript syntax tree. The formal artifact
TJS offers is a serializable type (JSON Schema plus $predicate), which is what TypeScript's
erasure cannot give you.
The consequence for AJS: its surface syntax may only grow into its AST. A new nicety must
either desugar into existing nodes, or be a deliberate, versioned, additive extension (new
node kinds, never a changed shape for an existing one). The AST root carries its format
version ("$ajs": 2 from the transpiler; unversioned and $ajs: 1 ASTs are still read), so
persisted agents stay valid. The format itself is documented in DOCS-AJS.md.
North star: JSON-Schema + predicates as the single source of truth for types
A TJS type is canonically a JSON-Schema node, optionally carrying a $predicate for the
computational part JSON-Schema cannot express; examples, Type, and TS-derived types are
projections of that one form. Use it as a decision lens — does this move types toward that
form, or away? — and see docs/type-system-north-star.md
for the design.