
TypeScript just went through the biggest change to its toolchain since it launched in 2012 — the compiler itself was rewritten from scratch. Here's what actually shipped, what's still catching up, and which patterns are worth adding to your code regardless of which version you're running.
The compiler got a decade's biggest upgrade
On July 8, 2026, TypeScript 7.0 reached general availability — the first stable release built on "Project Corsa," a full port of the compiler and language service from TypeScript-on-JavaScript to native Go. The headline number: Microsoft's own benchmark on the VS Code codebase showed full type-checking time dropping from 125.7 seconds to 10.6 seconds — roughly a 12x speedup, with Microsoft describing typical full-build gains as 8–12x.
The important part for how you think about adopting it: the language didn't change. The team preserved the same type-checking algorithms and semantics on purpose — they ported the implementation, not the design. Your .ts files and tsconfig.json options behave exactly the same; they just run faster.
The catch is tooling. TypeScript 7.0 doesn't yet ship a stable programmatic compiler API — that's targeted for version 7.1, which Microsoft has said is still months away. Anything that hooks directly into the compiler to do its job — Vue's Volar, Svelte's language tools, Astro's type checker, typescript-eslint, ts-jest — is pinned to TypeScript 6.0 until that API lands.
What this means practically:
Plain Node.js, React, and Next.js application code has no compiler-API dependency and can move to 7.0 today.
Teams on Vue, Svelte, or Astro, or anyone leaning on
typescript-eslint, should stay on 6.0 for now.You can run both in parallel: keep 6.x installed for emit and tooling while benchmarking 7.0's type-checking speed in CI.
What's worth keeping from 6.0
TypeScript 6.0 (March 2026) was the last release built on the old JavaScript-based compiler, and it wasn't just a bridge release:
Decorator metadata — Stage 3 decorators can now attach and read type metadata at runtime, powering a new generation of dependency-injection and validation libraries.
Import attributes — a standardized way to specify import conditions (like asserting a JSON import's type), replacing the older import-assertions syntax.
Sharper error messages — the compiler now suggests fixes for common mistakes like typos, instead of just flagging the problem.
Patterns worth knowing, whatever version you're on
Version churn aside, a handful of patterns consistently separate TypeScript that actually catches bugs from TypeScript that's just JavaScript with extra syntax.
1. satisfies over type annotations
Annotating a variable widens it to the declared type. satisfies checks a value against a type without throwing away the specific literal type TypeScript already inferred:
type Config = { mode: "development" | "production"; port: number };
const config = {
mode: "development",
port: 3000,
} satisfies Config;
// config.mode is still "development", not widened to `string`
2. Prefer unknown over any
any turns off type-checking for everything it touches, including code several calls downstream that has no idea it's operating on unchecked data. unknown forces you to prove what something is before you use it — exactly what you want for data crossing a boundary you don't control:
async function fetchUser(id: string): Promise<unknown> {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
function isUser(value: unknown): value is { id: string; name: string } {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"name" in value
);
}
const data = await fetchUser("abc");
if (isUser(data)) {
console.log(data.name); // safe — narrowed by the type guard
}3. Discriminated unions instead of optional-everything objects
Modeling state as one object with a pile of optional fields lets you construct impossible states — like loading: true and data both being set at once. A discriminated union makes those states unrepresentable, and pairing it with an exhaustiveness check means a new state added later can't silently fall through the cracks:
type RequestState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: string[] }
| { status: "error"; message: string };
function assertNever(value: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(value)}`);
}
function render(state: RequestState) {
switch (state.status) {
case "idle": return "Waiting to start";
case "loading": return "Loading…";
case "success": return state.data.join(", "); // data is known to exist here
case "error": return state.message;
default: return assertNever(state); // won't compile if a case is missing
}
}4. Branded types for values that are "just a string" until they aren't
IDs, emails, and currency amounts are usually strings or numbers at runtime, but treating them as interchangeable is how a postId ends up where a userId belongs. Branding catches it at compile time instead:
type UserId = string & { readonly __brand: "UserId" };
function getUser(id: UserId) { /* … */ }
const raw = "usr_123";
// getUser(raw); // Error: plain string isn't assignable to UserId5. as const for precise literal inference
Without it, TypeScript widens array and object literals to their general type — string[], not the specific values inside it. as const locks in the literals, which is a clean way to derive a type from a single source of truth instead of maintaining the values and the type separately:
const ROUTES = ["/home", "/about", "/settings"] as const;
type Route = (typeof ROUTES)[number]; // "/home" | "/about" | "/settings"
function navigate(route: Route) { /* … */ }
navigate("/home"); // OK
// navigate("/missing"); // Error: not a valid Route6. Utility types instead of duplicated shapes
Pick, Omit, Partial, and Record exist so your API-response type and your database type don't quietly drift apart:
interface User {
id: string;
name: string;
email: string;
passwordHash: string;
}
type PublicUser = Omit<User, "passwordHash">;
type ProfileUpdate = Partial<Pick<User, "name" | "email">>;7. Clean up resources with using instead of manual try/finally
Explicit resource management gives anything disposable automatic cleanup — no more forgetting to close a connection on the unhappy path:
class DbConnection {
[Symbol.dispose]() {
console.log("connection closed");
}
}
function runQuery() {
using db = new DbConnection();
// db's [Symbol.dispose]() runs automatically here, even if the line above throws
}8. Validate data at runtime boundaries
Type annotations disappear the moment your code compiles. Promise<User> is a promise to the compiler, not a guarantee about what a server actually sent back. A schema-validation library lets you define a shape once, derive the TypeScript type from it, and get a real check at the one place your types could be lying to you — the network boundary:
import { z } from "zod";
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
});
type User = z.infer<typeof UserSchema>;
async function fetchUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
return UserSchema.parse(await res.json()); // throws if the shape doesn't match
}9. Turn on the strict flags you're probably missing
strict: true is table stakes. Two flags people skip that catch real bugs:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
}
}noUncheckedIndexedAccess makes array[i] return T | undefined instead of just T — which is what actually happens at runtime when the index is out of bounds. exactOptionalPropertyTypes stops { name?: string } from silently accepting an explicit { name: undefined }.
Common pitfalls to avoid
A few habits that quietly undermine everything above:
// @ts-ignoreinstead of// @ts-expect-error.@ts-ignoresilences an error and stays silent forever, even if the surrounding code changes and the error turns into something worse.@ts-expect-errordoes the same suppression, but fails the build if there's no error left to suppress — so a stale suppression surfaces instead of hiding.Reaching for
asto make an error disappear. A type assertion tells the compiler "trust me," not "prove it." Occasionally necessary, but it's usually a sign a type guard or a schema check belongs there instead.Turning off
strictto unblock a migration and never turning it back on. A reasonable short-term move on a legacy codebase — just track it as debt with a ticket, not a permanent setting.Letting inferred types balloon instead of naming them. If a function's return type is a three-line inline object, extracting it into a named
typemakes error messages and editor tooltips dramatically more readable.
A practical checklist for right now
Running Node, React, or Next.js with no compiler-API tooling? Try TypeScript 7.0 in a branch and benchmark your CI's type-check step.
Running Vue, Svelte, Astro, or relying on
typescript-eslint? Stay on 6.0 and watch for the 7.1 API — Microsoft has said it's still some months out.Either way: audit your
tsconfig.jsonagainst the strict flags above. They're independent of which compiler you run.Grep for
anyandasin your codebase. Each one is a candidate forunknownplus a type guard, or a schema validator at the boundary.Adopt
satisfies, discriminated unions, and branded types incrementally. None of them require a version bump — just a PR.
The takeaway
2026 is a good year to separate two questions that usually get bundled together: "should I upgrade the compiler" and "should I write better TypeScript." The compiler upgrade is largely a waiting game for your tooling to catch up. Writing better TypeScript — satisfies, unknown over any, exhaustive unions, branded types, using, boundary validation, and a stricter tsconfig — is available today, in whatever version you're already running.
Further reading: Announcing TypeScript 7.0 on the official TypeScript DevBlog.
Comments (0)
Login to post a comment.