← Back to hometypescript

TypeScript in 2026: The Go-Powered Compiler and Type-Driven Development

← All writing

TypeScript is in the middle of the biggest infrastructure change in its history, and almost nothing about how you write it day to day has to change. That combination is rare enough to be worth writing about. Microsoft is porting the entire compiler and language service from TypeScript-running-on-Node to native Go, with a stated goal of roughly 10x faster builds and dramatically snappier editors. Meanwhile the language itself has quietly accumulated a set of features — satisfies, const type parameters, template literal types, ever-stricter compiler flags — that make a particular working style viable at scale: type-driven development, where the type system is the first tool you reach for to encode invariants, not a linter you appease afterwards.

We build web products for clients as a small senior team, which means we live with two constraints at once: codebases must stay fast to work in, and they must survive handover to people who weren't in the room when decisions were made. TypeScript, used deliberately, addresses both. Used carelessly, it addresses neither — you get slow builds and types that lie. This post covers where the toolchain actually stands right now, and the patterns we've settled on for making types earn their keep.

Illustration: a fast blue locomotive pulling a long train up a mountain pass

The compiler is being rewritten in Go — here's the actual status

In March 2025 Microsoft announced that TypeScript's compiler and language service were being ported to Go, under the codename Corsa (the existing JavaScript implementation is retroactively codenamed Strada). The code lives in the public microsoft/typescript-go repository, and Microsoft is careful to call it a port, not a rewrite: the Go codebase was methodically translated from the existing one, so its type-checking behavior is meant to be structurally identical to the JavaScript compiler's, not a reimagining of it.

The headline claim is roughly a 10x speedup on full type-checks, driven by native code, shared-memory parallelism across cores, and the removal of the JIT warm-up tax. Microsoft's own published example was checking the VS Code codebase — from over a minute down to single-digit seconds. Independent runs on type-heavy codebases have reported smaller but still substantial multiples, which is what you'd expect: the more time your build spends in pathological type instantiation rather than raw file traversal, the less parallelism alone saves you.

Where things stand as of this writing:

  • TypeScript 6.0 shipped in March 2026 and is the final release line built on the JavaScript codebase. It also serves as the bridge release, deprecating legacy options so the native compiler doesn't have to carry them.
  • The native compiler ships as TypeScript 7. It's available today in preview — the @typescript/native-preview package exposes a tsgo binary, and there's a companion VS Code extension for the native language service. GA is expected later this year; treat exact timing and final behavior as subject to change until it lands.
  • The big caveat is the compiler API. Tools that consume TypeScript programmatically — Vue and Svelte language tooling, Astro, Angular template checking, some ESLint type-aware rules — depend on APIs that don't have stable native equivalents yet. If your stack leans on those, you'll be on the 6.x line a while longer. Plain tsc-and-editor projects (which includes most Next.js/React work, like ours) can experiment now.

Our advice: don't rearchitect anything around it, but do run tsgo --noEmit against your repo today. It costs ten minutes, tells you whether you're relying on any deprecated options, and gives you a real number for what your CI type-check step will cost after the switch.

What faster checking changes in practice

A 10x compiler sounds like a CI line item, and it is — a four-minute type-check step becoming ~25 seconds changes how often you're willing to run it. But the deeper effect is on the editor. The language service is the same engine, and on large codebases it's the language service that hurts: slow go-to-definition, laggy autocomplete, multi-second waits for errors after a keystroke, project load times measured in tens of seconds.

That latency has been an invisible tax on type richness. Every team with a huge codebase has, at some point, simplified types specifically because the editor couldn't keep up — replaced a precise mapped type with Record<string, unknown>, split a package purely to shrink the program the language service had to hold. When checking gets an order of magnitude faster, the cost curve of expressive types flattens. Patterns that were theoretically correct but practically annoying — heavy discriminated unions, template-literal-typed route tables, generated types for an entire API surface — become free to use.

Which is exactly why the rest of this post matters. The compiler getting faster is Microsoft's job. Making the types worth checking is yours.

Parse, don't validate — with branded types

The single highest-leverage idea in type-driven development is this: validation should produce a value whose type proves the validation happened. If your function checks that a string is a valid user ID and then returns... a string, the knowledge evaporates immediately. Every downstream function must either trust blindly or re-check.

Branded (nominal) types fix this with zero runtime cost:

type Brand<T, B extends string> = T & { readonly __brand: B };

type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;

function parseUserId(raw: string): UserId {
  if (!/^usr_[a-z0-9]{12}$/.test(raw)) {
    throw new Error(`Invalid user id: ${raw}`);
  }
  return raw as UserId; // the one sanctioned cast, at the one sanctioned place
}

function loadOrders(userId: UserId): Promise<OrderId[]> {
  // ...
}

declare const someOrderId: OrderId;
loadOrders(someOrderId);
// ^ Error: Type 'OrderId' is not assignable to type 'UserId'.

Two IDs that are both "strings at runtime" are now distinct at compile time. Swapping arguments — the classic bug in any function taking (string, string) — is a compile error. The as UserId cast is confined to the parse function, which is the whole discipline: casts live at boundaries, inside functions whose job is to earn them. Anywhere else in the codebase, as in a code review is a question that needs answering.

The same pattern pays off for sanitized HTML, validated email addresses, non-empty arrays, and currency-tagged amounts. If a value has passed a check that matters, give it a type that says so.

Discriminated unions over boolean flags

Boolean flags are how impossible states sneak into production. Consider the classic:

// Don't: 2^3 = 8 representable states, maybe 4 of them valid
interface PaymentUI {
  isLoading: boolean;
  isSuccess: boolean;
  error: string | null;
}

What does { isLoading: true, isSuccess: true, error: "timeout" } mean? Nothing — but the type permits it, so eventually some code path produces it, and some render function does something surreal with it. Model the states you actually have:

type PaymentState =
  | { status: "idle" }
  | { status: "processing"; startedAt: number }
  | { status: "succeeded"; receiptUrl: string }
  | { status: "failed"; message: string; retryable: boolean };

function renderPayment(state: PaymentState): string {
  switch (state.status) {
    case "idle":
      return "Ready.";
    case "processing":
      return `Processing since ${new Date(state.startedAt).toISOString()}…`;
    case "succeeded":
      return `Done — receipt at ${state.receiptUrl}`;
    case "failed":
      return state.retryable ? `Failed: ${state.message}. Retry?` : "Failed.";
    default: {
      const exhaustive: never = state;
      return exhaustive;
    }
  }
}

Three things to notice. Each variant carries only the data that exists in that state — there is no receiptUrl to forget to null out during processing. Narrowing is automatic: inside the "failed" branch, state.retryable just exists. And the never-typed default arm makes the switch exhaustive: add a "refunded" variant next quarter and every switch in the codebase becomes a compile error listing exactly the places that need updating. That last property is the closest thing TypeScript has to a refactoring superpower, and it's free.

satisfies, const type parameters, and template literals — the useful kind

These three features share a theme: keeping types precise without annotating them wider than they are.

satisfies checks a value against a type without erasing what the compiler inferred:

const routes = {
  home: "/",
  pricing: "/pricing",
  blog: "/blog",
} satisfies Record<string, `/${string}`>;

routes.pricing; // type is "/pricing", not string — autocomplete still works

Annotate that object as Record<string, string> instead and you'd lose the literal types; skip the check entirely and a typo like "pricing" (no slash) sails through. satisfies gives you both: the constraint is enforced, the inference is preserved. We use it for route tables, theme tokens, feature-flag maps, and config objects consumed by stricter code downstream.

Const type parameters solve the "my array literal widened to string[]" problem at the API-design level, so callers don't need as const:

function defineNav<const T extends readonly { label: string; href: string }[]>(
  items: T,
): T {
  return items;
}

const nav = defineNav([
  { label: "Work", href: "/work" },
  { label: "Pricing", href: "/pricing" },
]);
// nav[0].href is "/work", not string

And template literal types are worth using precisely when they model a real string grammar your code already depends on — not as a party trick:

type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
type Endpoint = `${HttpMethod} /${string}`;

function register(endpoint: Endpoint, handler: () => Response): void {
  // ...
}

register("POST /api/scope", handler); // fine
register("FETCH /api/scope", handler);
// ^ Error — and this typo is now impossible, forever

Event-name conventions (`${Entity}:${Action}`), CSS custom property names, cache keys: anywhere your team has a string convention enforced by code review, a template literal type enforces it by compiler instead. The rule of thumb: if you can explain the type to a colleague in one sentence, it's modeling; if you can't, it's golf.

Runtime validation at the boundary: one source of truth

Static types are erased at runtime, and runtime is where the outside world lives. The answer is a schema library — we use Zod, and Valibot is a solid lighter-weight alternative — with the schema as the single source of truth from which the static type is derived:

import { z } from "zod";

const ProjectBrief = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  budget: z.number().int().positive(),
  timeline: z.enum(["asap", "1-3months", "3-6months", "flexible"]),
  stack: z.array(z.string()).default([]),
});

type ProjectBrief = z.infer<typeof ProjectBrief>;
// { name: string; email: string; budget: number;
//   timeline: "asap" | "1-3months" | "3-6months" | "flexible"; stack: string[] }

export async function POST(req: Request) {
  const parsed = ProjectBrief.safeParse(await req.json());
  if (!parsed.success) {
    return Response.json({ error: parsed.error.flatten() }, { status: 400 });
  }
  const brief = parsed.data; // fully typed, actually verified
  // ...
}

The anti-pattern is maintaining an interface and a validator by hand: they drift, silently, and the type keeps promising things the validator stopped checking months ago. z.infer makes drift structurally impossible.

Where to validate is as important as how: at every boundary where data enters your trust zone, and nowhere else. API route inputs, webhook payloads, third-party API responses, localStorage reads, environment variables at process startup. Once parsed, data flows through the interior on static types alone — re-validating in every layer is noise. This is "parse, don't validate" again, with the schema as the parser and z.infer as the proof.

Strictness knobs worth turning, and how to turn them on old codebases

"strict": true is table stakes. Two flags outside the strict family punch far above their weight:

FlagWhat it catchesTypical pain to adopt
noUncheckedIndexedAccessarr[i] and record[key] are T | undefined, matching runtime realityModerate — lots of small, honest fixes
exactOptionalPropertyTypes{ theme?: Theme } no longer accepts an explicit theme: undefinedLow–moderate, mostly at object-building sites

noUncheckedIndexedAccess is the one we insist on for new projects. Every out-of-bounds index and missing dictionary key in JavaScript history was type-checked as safe by default TypeScript; this flag ends that:

const scores: Record<string, number> = { alice: 97 };
const s = scores["bob"]; // number | undefined — as it should be

const first = list[0];   // T | undefined
first?.render();          // the compiler made you think about the empty case

For legacy codebases, do not flip flags and fix 1,400 errors in one heroic branch — that PR never merges. The strategy that works is a ratchet:

  1. Turn the flag on and get the error count. That number is now your metric.
  2. Suppress existing errors mechanically with // @ts-expect-error TODO(strict-migration) comments (a codemod can do this in minutes). The build is green again, but new code is checked at full strictness from day one — the debt stops growing immediately.
  3. Burn down the suppressions opportunistically: whoever touches a file removes its markers. @ts-expect-error helpfully errors when the underlying issue is fixed, so stale suppressions clean themselves up.
  4. Track the count in CI and fail if it rises.

In a monorepo, a coarser version works too: enable the flag package-by-package via per-package tsconfig overrides, strictest packages first. Either way the principle is the same — separate "stop the bleeding" from "heal the wound," because the first takes an afternoon and the second takes a quarter.

Monorepo type architecture

Once a repo passes a few hundred thousand lines, treating it as one giant TypeScript program stops working — for build times today and for the native compiler's parallelism tomorrow. Two structural moves matter.

Project references. Split the repo into composite projects ("composite": true, "references": [...]) and build with tsc -b. Each package emits .d.ts files; dependents type-check against those declarations instead of re-checking source. Builds become incremental and correctly ordered, and the language service can load a subgraph instead of everything. The tax is real — packages need clean dependency direction, and circular imports that "worked" before become build errors — but that tax is a feature: it's your architecture diagram being enforced.

Type-only contract packages. Extract shared domain types and schemas into packages that contain no runtime logic except schemas@yourorg/contracts holding the Zod schemas, inferred types, and branded-type constructors for entities that cross package boundaries. Frontend, backend, and workers all import the same ProjectBrief. These packages are nearly free to build, almost never create dependency cycles, and give API changes a single, reviewable location: when the contract package changes in a PR, everyone knows to look hard.

One habit worth adopting either way: keep "skipLibCheck": true (checking node_modules declarations is mostly someone else's noise) but run your own published declarations through a checker like @arethetypeswrong/cli in CI, because broken ESM/CJS type resolution is the modern "works on my machine."

Where types lie, and how to catch them

A type system is a proof system with holes, and knowing the holes is what separates confidence from superstition. The recurring liars:

  • any leakage. One any propagates: anyValue.foo.bar() is any all the way down, and it flows through inference into places you never wrote it. Turn on @typescript-eslint's no-explicit-any and the no-unsafe-* family; the latter catches inferred any flowing out of untyped dependencies, which is where most of it comes from.
  • JSON.parse and friends. It returns any, so const user = JSON.parse(raw) type-checks as anything you want. Wrap it once — const parseJson = (raw: string): unknown => JSON.parse(raw) — and let unknown force a schema parse at every use. Same for fetch response bodies: res.json() is a promise of any, which means every untyped fetch call is a hole. A thin typed fetch wrapper that takes a Zod schema closes all of them at once.
  • ORMs and query builders. Generated types describe the schema as the codegen last saw it, not the database in production, and raw/partial queries (select clauses, joins, JSON columns) are where the escape hatches live. Regenerate types in CI against migrations so drift fails the build, and treat any raw query result as unknown until parsed.
  • Environment and config. process.env.WHATEVER is string | undefined at best and a lie at worst. Parse the environment once at startup with a schema and export the typed result; a missing variable becomes a crash at boot with a good message instead of undefined in a template string at 2 a.m.

The unifying pattern: unknown in, parsed types out, any nowhere. unknown is the honest type for data you haven't looked at, and unlike any it refuses to let you touch it until you do.

Types as documentation — the team discipline

The cheapest documentation your codebase will ever have is a well-named type in a signature, because it's the only documentation the compiler keeps honest. A few norms we hold in review:

  • Name domain concepts, even when structurally trivial. type Cents = Brand<number, "Cents"> beats number in a signature not because the check is deep but because the name travels to every call site, hover tooltip, and autocomplete list.
  • No boolean parameters in public APIs. render(item, { compact: true }) or a two-variant union — never render(item, true). Booleans at call sites are unlabeled documentation.
  • Signatures state intent; comments state why. If a function accepts unknown, that's a claim ("I validate this"). If it accepts UserId, that's a different claim ("validation already happened"). Reviewers should read signatures as promises and reject ones that over- or under-promise.
  • Prefer types that make the next change safe. The exhaustive-switch pattern above is documentation with an alarm attached: the compiler pages whoever adds the next variant.

On a small senior team this discipline is what makes handover possible. The client's next developer won't read your Notion. They will, involuntarily, read your types.

When to stop: type complexity is tech debt

Everything above has a failure mode, and it's the same one: types whose cleverness exceeds their value. A five-level conditional type with three infers that saves callers one explicit annotation is not a win — it's a maintenance liability with a terrible error message. We use three tests:

  1. The error-message test. Trigger a mistake against the type on purpose. If the compiler's error doesn't point a mid-level developer at the fix, the type is too clever for its job.
  2. The explanation test. If you can't explain what the type guarantees in one sentence, it's not documenting anything — it's obfuscating with extra steps.
  3. The alternative test. Would a runtime check plus a simpler type give 95% of the safety at 10% of the complexity? Then take that deal. A thrown error with a clear message is not a defeat.

Type-level code is code: it has bugs, needs refactoring, resists modification, and — until the native compiler is everywhere — has a compile-time performance cost too. Budget it like any other complexity. The goal of type-driven development is not the maximum number of things proven at compile time; it's the cheapest possible proof of the invariants that actually bite.

Takeaways

  • The native compiler is real: announced as Corsa in March 2025, previewable now as tsgo, shipping as TypeScript 7 with ~10x claimed speedups. TypeScript 6.0 is the last JS-based line. Try tsgo --noEmit on your repo today; don't bet architecture on preview behavior yet.
  • Faster checking changes editors more than CI — it removes the performance excuse for imprecise types.
  • Parse, don't validate: checks should return branded types that prove they ran. Confine as to the parsing boundary.
  • Model states as discriminated unions, never as co-occurring booleans, and make every switch exhaustive with a never arm.
  • Use satisfies to constrain without widening, const type parameters to keep literals literal, and template literal types only for string grammars your code genuinely depends on.
  • One source of truth at boundaries: Zod/Valibot schema plus z.infer — never a hand-maintained interface next to a hand-maintained validator.
  • Turn on noUncheckedIndexedAccess and exactOptionalPropertyTypes; migrate legacy code with a suppress-then-ratchet strategy, not a heroic branch.
  • In monorepos: project references for incremental builds, type-only contract packages for shared domain types.
  • Types lie at any, JSON.parse, fetch, ORMs, and process.env — route all of it through unknown and a schema.
  • Type complexity is tech debt. If the error message can't guide a fix, simplify the type and let a runtime check carry the rest.

Enjoyed the read? We build this stuff for clients too.

Start a project