
TL;DR
I built
@ttsc/lint, a native TypeScript lint engine forttsc.It also ships the separate
ttsc formatcommand.On the VS Code benchmark fixture:
ttsctype-checks up to 10x faster than legacytsc;- the measured
@ttsc/lintpass is around 500x faster than ESLint;- the separate
ttsc formatcommand is around 70x faster thanprettier --check.Yes, 500x sounds like bait.
It is bait.
It is also measured, and the reason is simple: once TypeScript-Go has already built the Program, lint should not start a second TypeScript pipeline.
Benchmark: https://ttsc.dev/docs/benchmark
Repository: https://github.com/samchon/ttsc
Most TypeScript projects still run some version of this:
npx tsc --noEmit
npx eslint .
npx prettier --check .It feels normal because we have done it for years.
But if you step back, it is a weird amount of duplicate work.
The compiler reads the project, parses TypeScript, builds a Program, and checks types.
Then ESLint reads the project again, parses TypeScript again, builds another view of the code, and runs rules.
Then Prettier reads the project again, parses enough of it again, and checks whether the printed version is stable.
The source tree did not change between those commands.
The question did not change either:
Is this TypeScript project acceptable enough to merge?
We just ask it three different ways.
@ttsc/lint is my attempt to remove the second TypeScript pipeline.
Formatting is related, but it is not the same command. @ttsc/lint owns the format/* rules and the ttsc format command, but ttsc --noEmit and ttsc format are separate surfaces.
That separation is important.
The headline numbers come from the public ttsc benchmark. The reference project for this article is the VS Code fixture.
That matters. I do not want to hide the benchmark behind a vague “large project” claim. The fixture is concrete, recognizable, and big enough that startup noise is not the whole story.
On that fixture:
ttsc type-checks up to 10x faster than legacy tsc;@ttsc/lint pass is around 500x faster than ESLint;ttsc format is around 70x faster than prettier --check.The concrete row behind the lint headline is about 65.6 seconds for ESLint versus 129 milliseconds for the native @ttsc/lint time line in the ttsc --noEmit --checkers 2 run. That is 508x, rounded down to 500x.
This is not a universal promise that every repository will see exactly the same ratio. File count, rule set, TypeScript options, config shape, and plugins all matter.
But the direction is the important part.
Traditional linting pays for another parser and another rule runtime.
Traditional formatting pays for another parse-and-print pipeline.
@ttsc/lint moves lint into the native check lane, and gives formatting its own native command.
The title says “moved lint into TypeScript-Go”, so here is the precise version.
@ttsc/lint is not a fork of Microsoft’s tsgo binary. It is a ttsc check-stage native sidecar that builds on TypeScript-Go’s Program, AST, and Checker.
That is still the important move.
The old workflow looks like this:
tsc -> type diagnostics
@typescript-eslint -> lint diagnostics
prettier -> format checkThe ttsc workflow is split correctly:
ttsc --noEmit -> TypeScript-Go Program -> type diagnostics
-> native lint diagnostics
ttsc format -> TypeScript-Go AST -> format/* rewritesttsc --noEmit is the check path. It catches TypeScript diagnostics and lint diagnostics.
ttsc format is the write path. It rewrites source using the formatter rules.
Same package. Separate commands.
The compiler has already opened the project for the check path. It already knows the source files. It already has TypeScript diagnostics. If a lint rule needs type information, the Checker is there too.
So the linter should not rebuild the world.
This is where performance posts usually get sloppy, so let me be explicit.
I am not claiming a full command invocation costs zero milliseconds.
A real ttsc --noEmit run with @ttsc/lint still has surrounding work:
lint.config.ts;tsconfig.json;Those costs are real.
They are just not the lint pass.
The benchmark’s @ttsc/lint time line measures the native rule walk after the Program has already been loaded. In code terms, the timer starts immediately before the engine runs over the selected source files and stops immediately after that rule walk returns.
What remains is mostly:
That is why the additional lint cost can be so close to zero in practice.
Not literally zero.
Close enough that the old mental model breaks.
legacy check = tsc + eslint
ttsc check = TypeScript-Go check + tiny native lint passFor formatting, the benchmark is a different comparison:
legacy format = prettier --check
ttsc format = native format/* rule walkOn the same VS Code fixture, prettier --check takes about 118.3 seconds and ttsc format takes about 1.5 seconds in the default run. That is roughly 76x, so I call it 70x faster format.
Take a small file:
var x: number = 3;
let y: number = 4;
const z: string = 5;
console.log(x + y + z);There are three issues:
var x violates no-var;let y should be const;const z: string = 5 is a TypeScript type error.In the usual setup, TypeScript reports one of them and ESLint reports the other two.
With ttsc --noEmit and @ttsc/lint, they are all compiler-style diagnostics:
src/lint.ts:3:7 - error TS2322: Type 'number' is not assignable to type 'string'.
3 const z: string = 5;
~
src/lint.ts:2:5 - error TS17397: [prefer-const] Use const instead of let.
2 let y: number = 4;
~~~~~~~~~~~~~
src/lint.ts:1:1 - error TS11966: [no-var] Unexpected var, use let or const instead.
1 var x: number = 3;
~~~~~~~~~~~~~~~~~~The exact diagnostic codes are not the interesting part.
The shape is.
Types and lint share one terminal output and one CI gate.
Formatting stays a separate write command.
That is a different product from “ESLint, but faster.”
Install:
npm install -D ttsc @ttsc/lint @typescript/native-previewCreate lint.config.ts next to tsconfig.json:
import type { ITtscLintConfig } from "@ttsc/lint";
export default {
rules: {
"no-var": "error",
"prefer-const": "error",
"typescript/no-explicit-any": "warning",
},
format: {
printWidth: 100,
singleQuote: true,
trailingComma: "all",
},
} satisfies ITtscLintConfig;Run the check path:
npx ttsc --noEmitRun the separate format path:
npx ttsc formatUse autofix when you want lint and format edits applied before a re-check:
npx ttsc fixThe commands are intentionally boring. The interesting part is what they replace.
Early versions of this idea put lint options inside compilerOptions.plugins.
I backed away from that.
tsconfig.json should describe the TypeScript project. It can point at compiler plugins, but it should not become the home for every plugin’s private configuration language.
So @ttsc/lint uses dedicated config files:
lint.config.ts
lint.config.mts
lint.config.cts
lint.config.js
lint.config.mjs
lint.config.cjs
lint.config.jsonThe file is discovered by walking upward from the project. If a project needs an explicit path, the tsconfig plugin entry accepts configFile.
That keeps the migration legible:
.eslintrc.* -> lint.config.ts rules
.prettierrc.* -> lint.config.ts format
tsconfig.json -> TypeScript projectSmall boundary, big difference.
The package is called @ttsc/lint, but it also owns common TypeScript formatting.
That does not mean ttsc --noEmit secretly runs ttsc format.
It does not.
ttsc format is a separate command because formatting writes source files. That command uses format/* rules from @ttsc/lint:
format: {
printWidth: 100,
singleQuote: true,
trailingComma: "all",
}The formatter covers rules such as semicolons, quotes, trailing commas, print-width reflow, import ordering, and JSDoc normalization.
This does not mean “every Prettier plugin is replaced.”
It means the common TypeScript formatting path can stay in the same native toolchain while keeping the right command boundary:
npx ttsc --noEmit # type + lint
npx ttsc format # format@ttsc/lint does not run existing ESLint plugins.
It does not run existing Prettier plugins.
It does not mean every project should delete ESLint today.
That tradeoff is real:
| Path | Best at |
|---|---|
| ESLint | Huge JavaScript rule ecosystem |
| Prettier | Huge formatting ecosystem and language coverage |
@ttsc/lint | Native TypeScript-focused compiler integration |
If your repo depends on a pile of custom ESLint plugins, keep them.
If your repo mostly needs TypeScript rules, type-aware checks, core formatting, and faster CI feedback, @ttsc/lint is worth trying now.
You do not have to migrate in one dramatic pull request.
Start small. Run it beside the old pipeline. Delete old steps only after the new one becomes boring.
This article is about @ttsc/lint because 500x faster lint and a separate 70x faster format command are the easiest way to make the architecture obvious.
But this is not the whole ttsc story.
The bigger idea is:
TypeScript-Go should not only be a faster
tsc. It should become a base for a compiler-powered TypeScript toolchain.
That bigger story includes runtime execution, compiler plugins, bundlers, and ecosystem packages.
Those deserve their own posts.
This first one only needs to land one point:
Lint does not have to be a second TypeScript pipeline.
That is the opening shot.
Install:
npm install -D ttsc @ttsc/lint @typescript/native-previewCreate lint.config.ts:
import type { ITtscLintConfig } from "@ttsc/lint";
export default {
rules: {
"no-var": "error",
"prefer-const": "error",
},
format: {
printWidth: 100,
singleQuote: true,
trailingComma: "all",
},
} satisfies ITtscLintConfig;Run:
npx ttsc --noEmit
npx ttsc format
npx ttsc fixRead next:
The old workflow:
npx tsc --noEmit
npx eslint .
npx prettier --check .The @ttsc/lint workflow:
npx ttsc --noEmit # type + lint
npx ttsc format # formatSame package, separate commands.
That is the first chapter.