Why TJS

Everyone has tried to fix JavaScript. This is what they left on the table.

This chapter is for readers who already write JavaScript and know some of its history. New to programming? Start with Learn to Program instead; you can come back here later.

JavaScript has been getting fixed for twenty years. The attempts were good, most of them worked, and every one of them stopped somewhere short. TJS is an attempt to finish the job in the space they left.

Two words carry the argument, so here is what they mean. A fix is inside the language when it changes what the code actually does when it runs, and you choose it per file, the way 'use strict' changes how a file behaves. A fix is outside the language when it lives in a separate tool (a linter, a type checker) and the running program never hears about it. A fix is rationed when it is a fixed list that nobody can extend.

The short version: the fixes that worked were the ones inside the language, and they were rationed. The fixes that could grow without limit were the ones outside it, and being outside is what cost them.


What each attempt got right, and where it stopped

The doctrine: JavaScript: The Good Parts (2008)

Douglas Crockford's book made the foundational point, and it still holds: there is a good language inside JavaScript, and you get to it by subtraction. He named the parts that produce bugs and said: use the rest. His list included:

That was a radical claim. Not "JavaScript is bad" and not "JavaScript is fine", but that the language contains a smaller, coherent, better language, and that a disciplined programmer can decline the rest. Every attempt since, including this one, is a variation on that sentence.

What it could not do is be anywhere except in your head. A doctrine is enforced by memory and code review, and it has to be taught again to every newcomer on every team, forever. The book sold well, and the bad parts stayed in the language, stayed legal, and stayed what you got by default.

The enforcement: JSLint (2002)

Crockford had already made the doctrine executable, six years before the book explained it. JSLint, his linter, checked code against rules that had not yet been written down in prose. Its own warning label was "JSLint will hurt your feelings", and that sums up the whole category: a tool that can object to your code but cannot explain the rule in your editor, and cannot fix the language.

That is the structural limit: a linter can only scold. It cannot change what == does; it can only object, every time, forever. So the rules became a config file, the config file became a per-project argument, and the knowledge ended up in .eslintrc files and team convention rather than in the language. Turn a rule off and the hazard (a footgun, a feature that makes it easy to shoot yourself in the foot) is back.

Left on the table: the bad parts themselves. Being told == is wrong, in every project, by a tool that cannot fix it, is worse than == being right.

use strict (ES5, 2009)

The proof that the language could fix itself. Writing 'use strict' at the top of a file opts that file into stricter rules, and the rules are enforced by the JavaScript engine itself, so it removed things JSLint could only complain about: assigning to an undeclared name became an error instead of creating a global, with was gone, duplicate parameter names were gone, and assignments that used to fail without a word (writing to a read-only property, for example) started throwing errors.

It worked. It is the single most successful "fix JavaScript" project, and almost nobody thinks about it any more, which is what success looks like.

And then it stopped. The list is short and it never grew. == survived. typeof null survived. NaN !== NaN survived. There was no mechanism for "the next twenty things", because each one would have needed its own standards-committee cycle and its own compatibility argument.

Left on the table: everything after the first list. The mechanism was right and was used once.

TypeScript (2012) and Flow (2014)

Both understood that types are the missing information, and TypeScript in particular understood what Flow did not: adoption has to be gradual, and tooling has to be excellent. TypeScript won, deservedly, and it is better than what came before.

But look at what they are. Both are static checkers: they read your code before it runs, report problems, and then erase the types, so the program that actually runs has never heard of them. TypeScript describes a different language, a static one, and treats JavaScript as the compile target it lowers to.

That makes TypeScript a checking layer, not a new runtime meaning. Its value is real, and it is developer-experience value: editor intelligence, safe refactoring, errors caught before you run. What it does not do is change anything about how the program behaves. Its escape routes (any, as, @ts-ignore, .d.ts files that drift from the code they describe) are places where the checking stops, and nothing at run time takes over.

That would be a reasonable trade if JavaScript had no types of its own at run time. It has, and this is the part that gets missed.

JavaScript is often called weakly typed, because it converts values freely: '5' * 2 is 10, and '5' == 5 is true. That is a real problem, and TJS fixes it. But it is a different property from type safety in the strict sense: a value can never be read as something it is not. In that sense:

JavaScript is type-safe. TypeScript is not.

Every JavaScript value carries its type at run time, and nothing lets you reinterpret a string's bits as a number. The conversions are rules applied to values whose types are always known. That is real, enforced, and always present. A TypeScript type is a claim, unsound by design and erased before execution: as lets you assert something false, and nothing at compile time or run time will contradict you. One of these systems can be lied to, and it is not the one without a type checker.

So JavaScript already has the thing TypeScript is emulating. Its problem is not the absence of runtime types; it is that a few of them behave bizarrely:

typeof null           // 'object'  — a bug from 1995, never fixed
new Boolean(false)    // truthy!   — wraps false in an object, and objects are truthy
new String('')        // truthy!   — likewise

new Boolean(false) takes the primitive value false and wraps ("boxes") it in an object. Every object is truthy, so if (new Boolean(false)) takes the branch.

The set of types is also thin: number covers integers, floats and NaN (a value that typeof reports as 'number') alike, and there is no way to say "a non-negative integer" or "a valid CSS colour".

Which reframes the whole problem. The job is not to bolt a second, static, erasable type system on top. It is to fix the handful of broken runtime types and widen the vocabulary, staying in the system that actually enforces something. Run this in a .tjs file:

console.log(typeof null)                     // null (the string 'null')
console.log(new Boolean(false) == false)     // true: the boxed value is unwrapped
console.log(new Boolean(false) ? 'yes' : 'no') // no: a boxed false is falsy
console.log('5' == 5)                        // false: no type conversion
console.log(null == undefined)               // true: both mean "nothing"
console.log(NaN == NaN)                      // true
console.log(typeof NaN)                      // number: unchanged

== in TJS is === with the traps removed (boxed primitives, null versus undefined, and NaN never equalling itself), not JavaScript's converting ==. typeof NaN is still 'number': NaN is a number value, and TJS leaves that alone. The plain-JavaScript behaviour of each fix is still there when you need it, under a name that says what it is; see the third commitment below.

Widening the vocabulary looks like this. A parameter's example says what it accepts, and +0 means "a non-negative integer". A Type can carry a predicate for anything the examples cannot say:

function at(index: +0) { return ['a', 'b', 'c'][index] }

Type HexColor {
  example: '#ff8800'
  predicate(x) {
    return x.length === 7 && x.startsWith('#') &&
      x.slice(1).split('').every((c) => '0123456789abcdef'.includes(c))
  }
}

function paint(color: HexColor) { return `painting ${color}` }

console.log(at(1))                          // b
console.log(isMonadicError(at(-1)))         // true: -1 is not a non-negative integer
console.log(paint('#336699'))               // painting #336699
console.log(isMonadicError(paint('blue')))  // true: not a hex colour

Full CSS colours (names, rgb(), hsl() and the rest) are in tjs-lang/css; see Predicate types.

Left on the table: the runtime, and the recognition that the runtime already had a type system worth repairing.

ESM (ES6, 2015)

The other great inside-the-language success, and the closest precedent for what TJS is doing. Modules had been a community problem with community answers (AMD, CommonJS, UMD, bundler conventions), and the fix was not a better convention. It was new syntax with different semantics, living beside the old one.

import is not require with nicer spelling. Imports are resolved before any code runs, they always see the exporting module's current value of a binding rather than a copy, and a module is strict by default. The old form still works; nobody's code broke; the new form is better, and it won by being better.

That is the model. Not a lint rule, not a superset that erases, not a committee-sized list of removals: a real alternative inside the language, chosen file by file.

And .mjs is the mechanism we borrowed outright

Node had to run two incompatible module systems in one toolchain, and the answer was not a pragma. It was the file extension: .mjs means ESM, .cjs means CommonJS, and "type": "module" in package.json flips what a bare .js means. The unit of choice is the file, and the switch lives in its name.

That looks like a packaging detail and is actually a deep constraint. You cannot put "this is a module" inside the file, because you have to know it before you can parse the file. How imports resolve and whether the whole body is strict are all decided before the first token is read. A directive like 'use strict' can sit in the source because it changes so little; module-ness changes too much to be announced from within.

TJS has the same constraint, for the same reason: the dialect changes what the parser does, so it cannot be discovered inside the parse. Hence .tjs. (TJS once had in-file mode directives; they were removed in favour of the extension, arriving later and the hard way at the answer Node had already found.)


Where that leaves the gap

The table below asks three questions of each attempt:

inside JS? survives to runtime? unlimited scope?
The Good Parts no — a doctrine in your head no yes, but unenforced
JSLint no — a separate tool that scolds no yes, but advisory
use strict yes yes no — one fixed list
Flow no — a checker, types erased no yes
TypeScript no — a checker, types erased no yes
ESM yes yes no — one problem
TJS yes — repairs JS's own types yes yes

The obvious objection: TJS is also a separate file type compiled by a transpiler, exactly like TypeScript. The difference is what comes out. TypeScript's compiler deletes the types, so the JavaScript it emits behaves as if they were never written. TJS's transpiler turns them into code: the emitted JavaScript checks arguments, keeps the type information as data you can inspect, and runs == and typeof with the fixed meanings. The fixes are in the program that runs, not in a report that came before it.

Nobody has occupied the bottom row, and not because it is a bad idea. Each project had a good reason to stop: a committee cannot ship twenty semantic changes, a type checker that runs costs time on every call, and a linter that changes your program is frightening.

TJS's bet is that each of those reasons has weakened:

And the number of footguns that have been a lint rule for fifteen years (flagged, argued about, never removed) is the size of the opportunity.


What "inside JavaScript" actually means here

Three commitments, and they are testable rather than aspirational.

1. TJS accepts all of JavaScript. Every JavaScript program is accepted by the TJS toolchain. As a .js file it keeps JavaScript's meaning exactly. Renamed to .tjs, it still parses unchanged, but ==, typeof and the other fixes take effect. The extension is the gate, borrowed from .mjs, and nothing inside the file has to be remembered or configured. This is guarded rather than promised: a test in the TJS repository fails if TJS ever rejects code that plain JavaScript accepts.

2. Types are examples that survive. Instead of a separate type language that is erased, a type is a value:

function greet(name: 'Alice'): 'Hello, Alice!' {
  return `Hello, ${name}!`
}

const result = greet(42)
console.log(greet('Bob'))           // Hello, Bob!
console.log(isMonadicError(result)) // true
console.log(result.message)         // Expected string for '…greet.name', got number

'Alice' is not a string-literal type. It is an example, and it widens to "a string", so greet('Bob') is fine and greet(42) is refused at run time.

The refusal is returned, not thrown: greet(42) hands back an error value instead of raising an exception. A bad argument then cannot unwind through code that was never written to expect it; you check for it with isMonadicError(x) where you care, and the runtime also keeps a log of recent type errors so an ignored one is not lost.

The return annotation is a worked example: when the file is transpiled, TJS calls greet('Alice') and checks that the answer really is 'Hello, Alice!'. Change the template to Hi, ${name}! and the transpile (on this page, through tjs(), or with tjs check) stops with "Function signature example is inconsistent": the file fails its own test before any of it runs. (The tjs run command reports the failure but still runs the file; that gap is known.) Because the example really is called, a function whose example call should not happen at build time, one that writes files or calls a server, declares its return type with :! instead, which states the type without running anything.

Because the example stays a real value, the one annotation serves as documentation, a runtime contract, a test, and the source of a JSON Schema, the standard format that other tools (API validators, form builders, LLM tool definitions) read to know what a function accepts. One artifact rather than four that drift.

3. The escape hatches are named and ugly. Every fix can be opted out of, one place at a time, through something greppable and deliberately unattractive:

A confession, not a suppression:

console.log(typeof null)                     // null
console.log(LegacyTypeOf(null))              // object, JavaScript's answer
console.log(DangerousLegacyEquals('5', 5))   // true, JavaScript's converting ==

Search a codebase for Legacy and Dangerous and you have the complete list of places that still depend on the old behaviour. You can adopt TJS one file at a time and opt out one construct at a time, but there is deliberately no quiet project-wide switch that turns the fixes off.


Standing on their shoulders

The previous attempts are why this is possible, not competition for it. Crockford's list is still the list. use strict and ESM proved the inside-the-language route works and is survivable. TypeScript did the adoption research the hard way and established that a superset with gradual typing is something working programmers will accept.

TJS is what you get by taking those lessons seriously at the same time: fix the bad parts rather than flagging them, keep the fixes in the language rather than in a config file, and let the types live long enough to be useful.

The rest of this book takes those fixes one at a time. Three pages elsewhere on the site cover the same ground from other directions: