TypeScript Best Practices (2026)
Agent-facing 2026 TypeScript guidance: verified TS 6.0/7.0 release timeline (Go-native compiler RC), the excess-property-check structural-typing footgun with its exact trigger conditions, and strict-mode flags worth enabling beyond bare `strict: true`.
Assumes fluency with basic TypeScript syntax. No type-system primer.
Release timeline - verified, don't guess a version number
| Release | Status (2026-07) | Detail |
|---|---|---|
| TypeScript 5.9 | Superseded | Bridge release preceding 6.0 |
| TypeScript 6.0 | Shipped 2026-03-23 | Final JavaScript-based release; deprecates features to align with 7.0; type-checking behavior kept highly compatible with 7.0 |
| TypeScript 7.0 ("Project Corsa") | Release Candidate as of 2026-06-18 | Full compiler port to Go; GA expected roughly one month after RC (i.e., mid-to-late July 2026 - re-verify before citing as shipped); ~10x faster type-checking than 6.0; in 19,926 of 20,000 internal compiler test cases produces the same diagnostics as 6.0 (74 known divergences) |
Practical implication: a codebase on 6.0 today has a low-risk upgrade path to 7.0 once GA - the type-checking surface is deliberately near-identical. Do not assume 7.0 requires a rewrite; assume it requires a recompile and a spot-check of the 74 known-divergent cases if they matter to the codebase.
The excess-property-check footgun - a real, frequently-misunderstood inconsistency
TypeScript's structural typing means two independently-declared types with identical shape are interchangeable - except at fresh object-literal boundaries, where TypeScript applies a stricter check that does not follow from structural typing alone:
interface Config { mode: "aggressive" | "safe" }
function apply(c: Config) {}
apply({ mode: "safe", retries: 3 })
// Error: Object literal may only specify known properties,
// and 'retries' does not exist in type 'Config'.
const draft = { mode: "safe", retries: 3 }
apply(draft)
// No error - draft is a variable, not a fresh literal, so excess-property
// checking does not apply, even though draft's type is structurally
// assignable to Config either way.
This is not a bug - excess-property checking exists specifically to catch typos like colour vs color at the one point structural typing would otherwise let them through silently - but the trigger condition (fresh literal only, direct assignment or argument position) is the actual footgun: the exact same object shape is accepted or rejected purely based on whether it passed through an intermediate variable first. Real-world impact: refactoring apply({ ...literal }) into const cfg = {...}; apply(cfg) silently disables a typo check the team was relying on. Don't rely on excess-property checking as a substitute for a runtime schema validator (Best Practice below) - it is a literal-shape lint, not a type-safety guarantee.
Strict mode - the flags beyond bare strict: true worth calling out
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"module": "NodeNext",
"moduleResolution": "NodeNext",
"isolatedModules": true,
"skipLibCheck": true
}
}
noUncheckedIndexedAccess: without it,arr[i]is typedT, notT | undefined- TypeScript silently assumes every index access succeeds. This is not covered by plainstrict: true.exactOptionalPropertyTypes: without it,{ email?: string }treats "key absent" and "key present with valueundefined" as the same type, which is not true at runtime forJSON.stringify/structural equality checks - a frequent source of API drift bugs invisible to the type checker.
Runtime validation at boundaries - non-negotiable, type checking is compile-time only
import { z } from "zod"
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
role: z.enum(["admin", "member"]),
})
type User = z.infer<typeof UserSchema>
async function fetchUser(id: string): Promise<User> {
const response: unknown = await fetch(`/api/users/${id}`).then((r) => r.json())
return UserSchema.parse(response)
}
unknown, not any, at every boundary where data enters from outside the process (HTTP bodies, JSON.parse, env vars, third-party libraries without their own types) - any is contagious and disables checking for everything it touches downstream.
Discriminated unions over optional-field bags - unchanged, still the highest-leverage modeling pattern
type FetchState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error }
Makes { status: "success", error: someError } unrepresentable instead of merely unlikely.
as casts vs type guards vs @ts-expect-error
| Pattern | Runtime guarantee | Verdict |
|---|---|---|
value as User | None | Replace with a type guard or schema validation |
function isUser(v: unknown): v is User { ... } | Real, checked at the call site | Preferred |
@ts-ignore / @ts-nocheck | None, permanently silences the checker at that line | Forbidden - the underlying error is never resolved |
@ts-expect-error | None, but fails the build once the underlying error is fixed | Acceptable as a tracked, self-expiring suppression; require a comment explaining why |