TT Lab
Pricing

한국어 English 日本語

Get started
Blog

Blog

TypeScript 6 and the tsc-go Port — `using`, Decorators, `satisfies`, Type Narrowing (A Practical Deep Dive for 2026)

Hear it discussed · 10 minTwo hosts talk this post through

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.

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:

What gets faster, what stays the same

Gets faster:

Stays the same:

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.

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.

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

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

Under the new semantics, decorators attach to five kinds of targets:

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

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:

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


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.

  1. Change the compiler — to tsc-go. Biggest reward, smallest risk.
  2. Learn using — every resource-handling library will follow soon.
  3. Make satisfies your default — as only as a last resort.
  4. Move to Promise.withResolvers — one-line change that shines once a day.
  5. Use decorators where they belong — metaprogramming spots, else functions.

Checklist — apply once across a monorepo

Top 10 anti-patterns

  1. Escaping with any — unknown is almost always better. Narrow, then proceed.
  2. Forcing with as — check satisfies first.
  3. Nested try/finally — using is what you want.
  4. Hand-rolled "external resolve" with new Promise — Promise.withResolvers in one line.
  5. Decorator overuse — slapping @log on every method is a trap.
  6. Type-parameter explosion — more type params than function args? Rethink.
  7. Giant conditional types — compile slows down. Split, cache, test.
  8. Big config objects without satisfies — key typos slip through.
  9. enum usage — as const objects are almost always better (tree-shake, inference).
  10. 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

Sign in to like

Comments

No comments yet.

Sign in to leave a comment