Prologue — TypeScript in 2026 is two words
"We are rewriting tsc in Go. It is ten times faster." — Anders Hejlsberg, announcement notes, March 11, 2025
In March 2025, Anders Hejlsberg revealed a year of work from the TypeScript team — a port of tsc to Go, known as tsc-go. Across the same codebases, 10.4x faster type checking, 10.5x faster builds, half the memory. Microsoft's own code (1.5M LOC): 76 seconds down to 7.5.
The same year, the Explicit Resource Management proposal settled into Stage 3, and TypeScript shipped the using and await using declarations for real. Java's try-with-resources, Python's with, C#'s using — JavaScript finally has the same.
Those two define TypeScript in 2026. The compiler was rewritten. The language gained resource management.
A handful of other things quietly settled in.
- Decorators moved from Stage 3 to standard ES2025, and
@accessor/@methodpatterns took hold in libraries. - The
satisfiesoperator went from "new in 5.0" to "standard vocabulary." NoInfer<T>,consttype parameters, andinfer extendsmade inference noticeably sharper.isolatedDeclarationscut monorepo build times.- tsserver got perceptibly faster in editors.
This post revisits all of that from the angle of code you write every day. Which features actually change your code? Which are merely cool? Let's draw the line honestly.
Chapter 1 · The tsc-go Port — What "10x" really means
The facts first.
What was rewritten, and how
The original tsc was written in TypeScript itself — the compiler compiled itself. Elegant, but slow. Running on V8 with JIT and GC, handling a huge graph (types, symbols, nodes), it leaned heavily on memory too.
tsc-go is a separate implementation written from scratch in Go. Same AST, same type system, same diagnostic messages. Behavior identical, runtime different.
[original tsc] [tsc-go]
TypeScript Go
V8 / JIT native compile
GC pauses short GC, parallel
mostly single-threaded multi-thread friendly
~2x memory ~half memory
large monorepo 60-120s large monorepo 6-12s
Microsoft's published benchmarks:
- VS Code codebase: 77.8s -> 7.5s (10.4x)
- Playwright: 11.1s -> 1.1s (10.1x)
- TypeORM: 17.5s -> 1.3s (13.5x)
- date-fns: 6.5s -> 1.0s (6.5x)
What gets faster, what stays the same
Gets faster:
tsc --noEmittype checking — the most direct win.- Editor responsiveness (tsserver) — the Go-based tsserver answers hover, go-to-definition, and completion noticeably faster.
- CI build times — the type-check stage after node_modules is fetched.
- Monorepo project-reference builds — the dependency-graph walk parallelizes.
Stays the same:
- Bundling time — esbuild, swc, rolldown run separately. tsc-go doesn't speed them up directly.
- Runtime behavior — emitted JS is unchanged.
- The type system itself — same rules, same diagnostics. Compatibility is intentional.
@types/*packages — fully compatible with DefinitelyTyped.
Adoption status in 2026 (realistic)
tsc-go shipped as a preview in 2025 and reached its first GA release in early 2026 (as typescript-go, or via a tsc --go flag). TypeScript 6 ships both compilers side by side.
tsc— the existing JS implementation, compatibility guaranteed.tsc-go— the new Go implementation, recommended for new work.
The two live together for now. Significant effort went into matching diagnostics, but small differences do show up (which is exactly why both ship). When migrating a large codebase, the standard procedure has become running both compilers in parallel and diffing the diagnostics.
What changes in practice
The biggest change is a one-line swap in CI.
# before
npx tsc --noEmit
# 2026
npx tsc-go --noEmit
# or
TSC_BACKEND=go npx tsc --noEmit
In monorepos, tsc -b builds drop from minutes to seconds. That's the change you actually feel. "Waiting for type-check" effectively disappears.
Editors get faster hover, faster completion, faster diagnostics. VS Code now exposes a setting to choose typescript-go as the backend.
Honest verdict
Almost everyone adopts it. Risk is near zero (same diagnostics, same output). Reward is large (10x). Compatibility is guaranteed while both compilers coexist. The only trap is build scripts that assume tsc strongly — secondary flag output like --listFiles can differ in tiny ways.
Chapter 2 · using and await using — Resource management entered the language
The ECMAScript Stage 3 Explicit Resource Management proposal settled in 2025, and TypeScript shipped the using keyword for real. It went into ES2024 and stabilized into ES2025.
What was the problem?
Resources (file handles, DB connections, mutexes, transactions) must be closed. JavaScript had no language feature to guarantee that. try/finally was the standard idiom, but nesting and omissions were common.
// traditional pattern — try/finally hell
async function processFile(path: string) {
const handle = await fs.open(path, 'r')
try {
const lock = await acquireLock()
try {
const tx = await db.beginTransaction()
try {
// ... real work
} finally {
await tx.rollback()
}
} finally {
lock.release()
}
} finally {
await handle.close()
}
}
Hard to read, and the close order is dictated by the structure.
What using does
A variable declared with using calls Symbol.dispose (or Symbol.asyncDispose) automatically when it leaves scope.
async function processFile(path: string) {
using handle = await fs.openWithDispose(path, 'r')
using lock = await acquireLockWithDispose()
await using tx = await db.beginTransactionAsync()
// ... real work
// on scope exit, cleanup runs in reverse order: tx -> lock -> handle
}
Symbol.dispose and Symbol.asyncDispose are themselves standard symbols. Anything that holds a resource can implement them and cooperate with using.
class DbConnection {
async [Symbol.asyncDispose]() {
await this.client.end()
}
}
{
await using db = await DbConnection.connect(url)
await db.query('SELECT 1')
} // db is closed automatically here
DisposableStack — dynamic resource bundles
When the number of resources is dynamic, reach for DisposableStack / AsyncDisposableStack.
async function pipeline(urls: string[]) {
await using stack = new AsyncDisposableStack()
const connections = await Promise.all(
urls.map(async (u) => {
const conn = await connect(u)
stack.use(conn) // gets closed when stack disposes
return conn
}),
)
// after all work, stack unwinds in reverse order
}
Where this actually changes code
What using truly changes is code with complex error flow. When two or three places can throw, try/finally nesting explodes. using flattens it.
- File-system handles
- DB transactions and connection pools
- Temp directories (
tmpDir) - Locks (including distributed locks)
- Timing (
using timer = startSpan()) - Test fixtures (setup/teardown)
Honest verdict
Adopt from libraries first. Libraries that abstract resources (DB drivers, file I/O, logging) should implement Symbol.dispose / asyncDispose — that's how users get to write using. Application code follows naturally. Don't rewrite all your try/finally — using it in new code is enough.
Runtime support: Node 22+, Bun 1.1+, Deno 1.46+ all support it without a polyfill. Below that, TS down-levels the code, but the runtime needs to know Symbol.dispose for cleanup to fire.
Chapter 3 · Promise.withResolvers() — A promise resolved from outside
Small feature, used daily. ES2024 standard, present in every major runtime.
What was the problem?
When you need "a promise that gets resolved from outside," we kept repeating this pattern.
let resolve!: (value: string) => void
let reject!: (err: unknown) => void
const promise = new Promise<string>((res, rej) => {
resolve = res
reject = rej
})
button.addEventListener('click', () => resolve('clicked'))
button.addEventListener('error', (e) => reject(e))
const value = await promise
The let resolve!: ... assertion was a nuisance. TS can't know resolve is definitely assigned.
Promise.withResolvers()
const { promise, resolve, reject } = Promise.withResolvers<string>()
button.addEventListener('click', () => resolve('clicked'))
button.addEventListener('error', (e) => reject(e))
const value = await promise
Types are clean. Intent is obvious. The line gets shorter.
Where it shines
- Waiting for a single event — DOM events, EventEmitter, message channels.
- Cancellable operations — clean teardown via
reject(new AbortError())from outside. - Test synchronization — the standard form of "wait for signal X."
- Reactor patterns — a worker loop that blocks until the queue has items.
class Queue<T> {
private items: T[] = []
private waiters: Array<{ resolve: (v: T) => void }> = []
push(item: T) {
const waiter = this.waiters.shift()
if (waiter) waiter.resolve(item)
else this.items.push(item)
}
async pop(): Promise<T> {
if (this.items.length > 0) return this.items.shift()!
const { promise, resolve } = Promise.withResolvers<T>()
this.waiters.push({ resolve })
return promise
}
}
Verdict
Just use it. Risk is zero. Available everywhere. New code defaults to this.
Chapter 4 · satisfies and template literal types — Standard vocabulary now
The two features that arrived in 5.0 are no longer "advanced features" in 2026. They mix into all code naturally.
satisfies — check without losing inference
The trap of the older pattern.
type Color = { hex: string; rgb: [number, number, number] }
// 1) annotation — loses inference
const palette: Record<string, Color> = {
red: { hex: '#ff0000', rgb: [255, 0, 0] },
green: { hex: '#00ff00', rgb: [0, 255, 0] },
}
palette.red.hex // type: string (specific keys gone)
// 2) as — loses safety
const palette2 = {
red: { hex: '#ff0000', rgb: [255, 0, 0] },
} as Record<string, Color>
palette2.blue.hex // compiles OK, undefined at runtime
satisfies solves both.
const palette = {
red: { hex: '#ff0000', rgb: [255, 0, 0] },
green: { hex: '#00ff00', rgb: [0, 255, 0] },
} satisfies Record<string, Color>
palette.red.hex // type: '#ff0000' (literal preserved)
palette.blue // compile error — missing key
Keep the concrete type, enforce the constraint. It belongs in route definitions, config objects, tuple maps — everywhere.
Template literal types — tie routes and APIs to the type system
Route strings floating around the codebase always break. Template literal types tie them together.
type Route =
| '/users'
| '/users/:id'
| '/posts'
| '/posts/:id/comments'
type Params<R extends string> =
R extends `${infer _}:${infer P}/${infer Rest}`
? { [K in P | keyof Params<`/${Rest}`>]: string }
: R extends `${infer _}:${infer P}`
? { [K in P]: string }
: {}
function navigate<R extends Route>(route: R, params: Params<R>) {
// ...
}
navigate('/users/:id', { id: '42' }) // OK
navigate('/users/:id', {}) // error — id missing
navigate('/users/:userId', { id: '42' }) // error — :userId not in Route
Same pattern lands in fetch wrappers, tRPC, tracing, permission systems.
type ApiMap = {
'GET /users': { params: void; result: User[] }
'GET /users/:id': { params: { id: string }; result: User }
'POST /users': { params: { body: NewUser }; result: User }
}
async function call<K extends keyof ApiMap>(
endpoint: K,
params: ApiMap[K]['params'],
): Promise<ApiMap[K]['result']> {
// ...
}
Verdict
Use satisfies everywhere. Keep as as a last resort. Use template literal types only where the boundary is sharp — routes, APIs, event names. Overusing them in general code slows compilation.
Chapter 5 · Decorators — Stage 3 to ES2025
Decorators walked a long, rough road. First proposed in 2014, redesigned multiple times, reaching Stage 3 in 2022, and finally landing as part of ES2025. TypeScript adopted the new semantics for real in 5.0. In 2026 they're "just a feature you use."
What changed
- Legacy decorators (
experimentalDecorators) — based on the early TC39 design, used by Angular, NestJS, TypeORM. - Standard decorators (Stage 3 -> ES2025) — clear calling semantics, type-safe, new categories like
@accessor.
Under the new semantics, decorators attach to five kinds of targets:
- The class itself
- Methods
getter/setteraccessor(auto getter/setter backed by a private field)- Fields
Each decorator receives a context object as its second argument: name, kind, and addInitializer (a callback that fires after the instance is initialized).
Practical example — @logged
function logged<T extends (this: any, ...args: any[]) => any>(
target: T,
context: ClassMethodDecoratorContext,
): T {
const name = String(context.name)
return function (this: unknown, ...args: unknown[]) {
console.log(`-> ${name}(${args.join(', ')})`)
const ret = (target as any).apply(this, args)
console.log(`<- ${name} =`, ret)
return ret
} as T
}
class Calculator {
@logged
add(a: number, b: number) {
return a + b
}
}
new Calculator().add(2, 3)
// -> add(2, 3)
// <- add = 5
accessor — a new category
function trace<T>(
target: ClassAccessorDecoratorTarget<unknown, T>,
context: ClassAccessorDecoratorContext,
): ClassAccessorDecoratorResult<unknown, T> {
return {
get() {
const value = target.get.call(this)
console.log(`read ${String(context.name)} =`, value)
return value
},
set(v) {
console.log(`write ${String(context.name)} =`, v)
target.set.call(this, v)
},
}
}
class Counter {
@trace
accessor value = 0
}
The accessor keyword tells the compiler to auto-generate a private field with a getter/setter pair. The decorator hooks both.
Library ecosystem
- NestJS 11+ — full migration to standard decorators (in progress through 2025, with both supported behind a flag).
- TypeORM 0.4+ — migration to the new semantics in progress.
- MobX 7 — built on the new semantics from day one.
- tRPC — sticks with the decorator-free style.
Honest verdict
Use them where they belong. Where metaprogramming fits (ORM, DI, validation, logging, metrics), decorators make sense. Elsewhere, a function is almost always better. Legacy code with experimentalDecorators on should migrate gradually — large task, large payoff (type safety, standard compliance).
Chapter 6 · Inference evolution — NoInfer, const type parameters, infer extends
TypeScript's inference sharpens every version. Three things that show up in everyday 2026 code.
NoInfer<T> — block inference direction
When a function signature takes the same type parameter in two places, inference can go against intent.
function pick<T>(items: T[], fallback: T): T {
return items[0] ?? fallback
}
pick(['a', 'b'], 'c') // T = 'a' | 'b' | 'c' (unintended)
NoInfer disables one slot's contribution so inference only happens at the other.
function pick<T>(items: T[], fallback: NoInfer<T>): T {
return items[0] ?? fallback
}
pick(['a', 'b'], 'c') // error — 'c' isn't 'a' | 'b'
pick(['a', 'b'] as const, 'a') // OK
Common in library signatures. Useful when you want a precise error instead of a permissive inference.
const type parameters — preserve literals
function tuple<T extends readonly unknown[]>(...items: T): T {
return items
}
tuple(1, 'a', true) // T: (number | string | boolean)[]
Add const, and the caller behaves as if they wrote as const.
function tuple<const T extends readonly unknown[]>(...items: T): T {
return items
}
tuple(1, 'a', true) // T: readonly [1, 'a', true]
Useful for any library that uses caller input as keys — state machines, routers, permissions.
infer extends — narrow what infer returns
type ToNumber<S extends string> =
S extends `${infer N extends number}` ? N : never
type N = ToNumber<'42'> // 42
type X = ToNumber<'abc'> // never
infer N extends number matches only when N satisfies number. Powerful for string parsing, route parameter types, SQL builders.
Verdict
All three are most useful for library authors. Application code sees them less often. Code works fine without knowing them, but learning them makes library APIs measurably cleaner.
Chapter 7 · isolatedDeclarations — Monorepos get fast again
An option introduced in 5.5. By 2026 it's standard kit in monorepos.
What was the problem?
For a library package to emit .d.ts, the TS compiler must fully infer the types of every public API. That's expensive.
// pkg/a/src/index.ts
import { compute } from 'somewhere'
export const result = compute('input')
// compiler: to know result's type, follow compute's signature
// -> have to see all of somewhere's graph
In monorepos, if package A's build depends on package B's types, B's changes invalidate A's build.
isolatedDeclarations
Turn this on, and the compiler requires explicit type annotations on every public export.
// same as above, with explicit annotations
import { compute } from 'somewhere'
export const result: string = compute('input')
The payoff? Declaration files can be emitted without inference. Tools like esbuild, swc, and oxc can produce .d.ts without the TS compiler. Monorepo builds parallelize and cache.
Verdict
Worth adopting for library packages and public SDKs. There's a one-time annotation pass, but build times drop by an order of magnitude. Good for internal monorepo packages too. No need to force on application code.
Chapter 8 · The runtime-validation landscape — Zod 4, ArkType, TypeBox
Types only live at compile time. Data coming from outside (API responses, DB results, env vars, LLM outputs) needs runtime validation. The 2026 landscape.
Zod 4
The most widely adopted choice. v4 (2025) brought:
- About 3x faster parse vs v3.
- Tree-shakable structure for smaller bundles.
z.discriminatedUnionstabilized.- Error messages support i18n.
import { z } from 'zod'
const User = z.object({
id: z.string().uuid(),
email: z.string().email(),
age: z.number().int().min(0),
})
type User = z.infer<typeof User>
const parsed = User.parse(rawJson) // throws on invalid
ArkType
A validator whose syntax mirrors TypeScript type expressions. You write the type and the schema in one go.
import { type } from 'arktype'
const User = type({
id: 'string',
email: 'email',
age: 'number.integer >= 0',
})
type User = typeof User.infer
const out = User(rawJson)
if (out instanceof type.errors) console.error(out.summary)
else /* out is User */
Very low runtime cost. Shines where you validate large payloads often (API gateways, message-queue consumers).
TypeBox
Emits JSON Schema directly. Plays nicely with the AJV / OpenAPI side of the world.
import { Type, Static } from '@sinclair/typebox'
const User = Type.Object({
id: Type.String({ format: 'uuid' }),
email: Type.String({ format: 'email' }),
age: Type.Integer({ minimum: 0 }),
})
type User = Static<typeof User>
Right for APIs that publish JSON Schema and backends that auto-generate OpenAPI docs.
How to pick
- Zod 4 — safest default. Largest ecosystem and docs.
- ArkType — hot paths where performance matters; or when you want the type expression to be the schema.
- TypeBox — environments where JSON Schema is a first-class citizen.
Chapter 9 · What to actually adopt — an honest matrix
One-line adoption value per feature.
| Feature | Adopt | Notes |
|-------------------------------|-----------|----------------------------------------|
| tsc-go | now | nearly free 10x. flip CI first. |
| `using` / `await using` | gradual | libraries first, new code first. |
| `Promise.withResolvers()` | now | zero risk, all new code. |
| `satisfies` | now | replaces nearly every `as`. |
| Template literal types | selective | routes, APIs, event names only. |
| Standard decorators | libraries | ORM, DI; metaprogramming spots. |
| `NoInfer<T>` | libraries | API signature precision. |
| `const` type parameters | libraries | literal preservation matters. |
| `infer extends` | sometimes | string parsing, routes. |
| `isolatedDeclarations` | libraries | monorepos and public SDKs. |
| Zod 4 | default | external data validation. |
| ArkType | hot path | performance-sensitive spots. |
| TypeBox | OpenAPI | JSON Schema ecosystems. |
Epilogue — TS 6 in two words, and five habits
Compress every page above into five lines.
- Change the compiler — to
tsc-go. Biggest reward, smallest risk. - Learn
using— every resource-handling library will follow soon. - Make
satisfiesyour default —asonly as a last resort. - Move to
Promise.withResolvers— one-line change that shines once a day. - Use decorators where they belong — metaprogramming spots, else functions.
Checklist — apply once across a monorepo
- On TypeScript 6 (or 5.7+)
- Enable
tsc-goin one CI job first -> diff diagnostics -> switch defaults -
tsconfig.jsonwith"target": "es2024","lib": ["es2024", "dom"] - Use
usingin new code — start from resource-abstraction libraries - Audit
as-> migrate tosatisfieswhere possible -
isolatedDeclarations: trueon library packages -
experimentalDecorators: false— unify on new semantics - Migrate to Zod 4 (v3 is winding down)
- Enable the
tsserver-go backend in your editor
Top 10 anti-patterns
- Escaping with
any—unknownis almost always better. Narrow, then proceed. - Forcing with
as— checksatisfiesfirst. - Nested
try/finally—usingis what you want. - Hand-rolled "external resolve" with
new Promise—Promise.withResolversin one line. - Decorator overuse — slapping
@logon every method is a trap. - Type-parameter explosion — more type params than function args? Rethink.
- Giant conditional types — compile slows down. Split, cache, test.
- Big config objects without
satisfies— key typos slip through. enumusage —as constobjects are almost always better (tree-shake, inference).- Misaligned DefinitelyTyped versions — sync runtime version with
@types/*.
Next post
Next up: Rust 1.85 and the maturity of async Rust — the landscape after async fn in trait stabilized, the split between Tokio / async-std / smol, comparisons across axum / actix / loco, and an honest guide to when to adopt Rust and when not to. Same tone, same depth, same one-pass.
"TypeScript's real value reveals itself slowly, after it becomes the standard. What we see in 2026 is that the compiler can be rewritten and the code stays the same." — TS 6 and the tsc-go port, end.
References
- TypeScript official site
- TypeScript 6 release notes
- tsc-go GitHub repository
- Anders Hejlsberg — A 10x Faster TypeScript (2025)
- TC39 Explicit Resource Management proposal
- TC39 Stage 3 decorators proposal
- TC39 Promise.withResolvers proposal
- TypeScript 5.0 — Decorators
- TypeScript 5.2 —
using - TypeScript 5.4 —
NoInfer<T> - TypeScript 5.5 —
isolatedDeclarations - Zod docs
- ArkType docs
- TypeBox GitHub
- Node.js 22 —
usingsupport - V8 — Promise.withResolvers
- MDN — Symbol.dispose
- DefinitelyTyped
- tsserver-go RFC