TypeScript

TypeScript Best Practices for 2026

TypeScript is easy to write and harder to change. A production checklist for strict flags, boundary validation, and types that prevent bugs.

Maruf Hasan

Full-Stack Web Developer

Published

Updated

8 min read
On this page
  1. 1. Strict flags that pay for themselves
  2. 2. Let inference do the boring work
  3. 3. Check with satisfies, don't cast
  4. 4. Make impossible states impossible
  5. 5. Validate at the edge, trust inside
  6. 6. Reach for unknown, not any
  7. 7. Prefer unions to enums
  8. 8. Name types like variables
  9. 9. Keep types out of the bundle
  10. 10. Put the type checker in CI
  11. 11. A tsconfig for 2026
  12. 12. The checklist

TypeScript has won. In 2026 it is the default way most teams write JavaScript, and the average codebase is more typed than it has ever been. Yet plenty of those codebases are still hard to change, because a type annotation can document a bug just as easily as it can prevent one.

The difference is rarely how much TypeScript you write. It is where you put it. This is the checklist I use on production code: the compiler flags that catch real mistakes, the patterns that make invalid states hard to represent, and the habits that stop a type layer from becoming decoration.

1. Strict flags that pay for themselves

strict: true is table stakes in 2026, but it is not the end of the story. A handful of additional flags catch mistakes that strict leaves on the table:

  • noUncheckedIndexedAccess makes indexing return T | undefined, which is what actually happens. It is noisy in code that assumes arrays are non-empty, and that noise is the point.
  • exactOptionalPropertyTypes separates { name?: string } from { name: string | undefined }. Without it, an explicit undefined leaks into objects where a property was supposed to be absent.
  • noImplicitOverride forces override on subclass methods, so a rename in the base class cannot silently orphan a method.
  • noFallthroughCasesInSwitch turns an accidental missing break into a compile error.
  • noPropertyAccessFromIndexSignature makes you write env["API_KEY"] instead of env.API_KEY, so a typo does not read as a valid property.
  • verbatimModuleSyntax and isolatedModules keep type imports explicit, which matters when a bundler or Node strips types without checking them.
  • erasableSyntaxOnly rejects syntax that cannot be erased, such as enum and constructor parameter properties, so the source can run under Node's built-in type stripping.
tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,
    "noPropertyAccessFromIndexSignature": true,
    "verbatimModuleSyntax": true,
    "erasableSyntaxOnly": true
  }
}

Turning all of these on at once in a large codebase produces a wall of errors that nobody reads. Add one flag, fix the fallout, merge. The compiler is a reviewer that never gets tired; give it one review at a time.

2. Let inference do the boring work

The compiler is better than most people at inferring local types. Annotating a variable that is initialised on the same line adds noise, and it can widen the type at the same time:

ts
// Redundant: the annotation is now the source of truth.
const roles: string[] = ["admin", "editor"];

// Better: the literal types survive, and the union follows from the array.
const roles = ["admin", "editor"] as const;
type Role = (typeof roles)[number];

Annotate the edges instead: function parameters, exported return types, and data that crosses a boundary. Those annotations are contracts. The local ones are usually just comments that can go stale.

ts
// The return type is a public contract, so keep it explicit.
export function parseRole(value: string): Role | null {
  return roles.find((role) => role === value) ?? null;
}

// The parameter type forces callers to narrow before calling.
export function isAdmin(role: Role): boolean {
  return role === "admin";
}

Explicit return types on exported functions have a second benefit: they make accidental API changes visible. If the body changes the shape of the result, the compiler makes you say so in the signature.

3. Check with satisfies, don't cast

as is an escape hatch, and every use is a place where the compiler stops helping. satisfies checks a value against a type without replacing its inferred type, which is exactly what you want for configuration objects, route maps and design tokens.

ts
type Route = { path: string; title: string };

const routes = {
  home: { path: "/", title: "Home" },
  blog: { path: "/blog", title: "Blog" },
} satisfies Record<string, Route>;

// Still known: routes.home.path is a string literal, not just string.
const homePath = routes.home.path;

The same trick works with as const satisfies when you need literal types and validation at once. If a key is misspelled or a required property is missing, the error appears at the object, not at some distant usage.

A cast is still reasonable when you genuinely know more than the compiler, such as a test double for a large interface. In that case, keep the cast as narrow as possible and say why it is safe. A cast on a value that came from the network is not knowledge; it is a guess.

4. Make impossible states impossible

Booleans multiply. A component with isLoading, data and error has eight combinations, and only three of them are real. A discriminated union expresses the three real states and makes the other five unrepresentable.

ts
type RequestState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };

function render(state: RequestState<User>) {
  switch (state.status) {
    case "idle":
      return "Nothing requested yet";
    case "loading":
      return "Loading…";
    case "success":
      return state.data.name;
    case "error":
      return state.error.message;
    default: {
      const exhaustive: never = state;
      return exhaustive;
    }
  }
}

The never assignment is the part people skip. It turns adding a new variant into a compile error in every switch that needs updating, instead of a silent fallthrough.

5. Validate at the edge, trust inside

TypeScript disappears at runtime. A response from fetch, a JSON file, an environment variable, or a form submission is unknown no matter what the annotation says. Parse it once, at the boundary, and pass the parsed type inward.

lib/users.ts
import { z } from "zod";

const UserSchema = z.object({
  id: z.uuid(),
  email: z.email(),
  name: z.string().min(1),
});

type User = z.infer<typeof UserSchema>;

export async function getUser(id: string): Promise<User> {
  const response = await fetch("/api/users/" + id);
  if (!response.ok) throw new Error("Request failed: " + response.status);
  return UserSchema.parse(await response.json());
}

Zod, Valibot and ArkType all solve the same problem: one schema produces both the runtime check and the static type. The library matters less than the placement. The unvalidated value never gets a name that sounds safe.

ts
// A hope, not a type.
const user = (await response.json()) as User;

// A checked value.
const user = UserSchema.parse(await response.json());

6. Reach for unknown, not any

any turns off the type system and then spreads. One any in a helper can make every caller's mistakes invisible. unknown is the honest version of the same value: it says “I don’t know yet” and forces a narrowing step at the point where you do.

ts
function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null;
}

function readName(payload: unknown): string | null {
  if (!isRecord(payload)) return null;
  return typeof payload.name === "string" ? payload.name : null;
}

try {
  // ...
} catch (error: unknown) {
  const message = error instanceof Error ? error.message : "Unknown error";
}

A type guard is a promise you make to the compiler, so keep it small and test it. If the check gets complicated, that is usually a sign the data shape needs simplifying, not that the guard needs more branches.

7. Prefer unions to enums

Enums generate runtime objects, do not erase under Node's type stripping, and have a surprising set of behaviours around numeric values and reverse mappings. A const array plus a derived union is simpler, tree-shakeable and just as type-safe.

ts
export const ROLES = ["admin", "editor", "viewer"] as const;
export type Role = (typeof ROLES)[number];

export function isRole(value: string): value is Role {
  return (ROLES as readonly string[]).includes(value);
}

When you need labels or metadata, use a map keyed by the union:

ts
const roleLabels = {
  admin: "Administrator",
  editor: "Editor",
  viewer: "Viewer",
} satisfies Record<Role, string>;

If an existing API forces you to use enums, keep them at the boundary and convert to unions in your own code. That way the awkward runtime semantics stay in one adapter instead of spreading through the app.

8. Name types like variables

A type name is documentation that appears in every editor tooltip and every error message. Data, Info and T describe nothing. Prefer names from the domain: Invoice, InvoiceId, CreateInvoiceInput, InvoiceStatus.

IDs are a special case. A string ID can be passed to any function that takes a string, including the wrong one. A branded type turns that into a compile error with no runtime cost:

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

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

function invoicePath(id: InvoiceId) {
  return "/invoices/" + id;
}

declare const userId: UserId;
invoicePath(userId); // Error: UserId is not assignable to InvoiceId

Branding has a cost: every value crossing a boundary needs a constructor. Use it for identifiers that are easy to confuse and expensive to mix up, not for every string in the codebase.

9. Keep types out of the bundle

With verbatimModuleSyntax, TypeScript stops guessing whether an import is a type. You mark it explicitly, and the bundler can drop it:

ts
import type { User } from "./types";
import { formatDate } from "./dates";

export function greet(user: User): string {
  return formatDate(user.createdAt) + ": " + user.name;
}

This is not only a bundle-size argument. Type-only imports cannot create a runtime circular dependency, and they make the dependency direction obvious in review. If a module only needs a type, it should not be importing the module's code.

10. Put the type checker in CI

A type check that only runs in the editor is a suggestion. Run tsc --noEmit in CI on every pull request, using the same TypeScript version the project pins. Locally, tsc --noEmit --watch is fast enough to leave running in a terminal.

Do not paper over errors. A @ts-expect-error is acceptable when the line is genuinely wrong at the type level and the reason is written next to it. @ts-ignore is not: it suppresses the error today and every related error tomorrow.

ts
// @ts-expect-error -- the SDK types lag behind the runtime API
client.experimentalFeature();

For shared packages, add type tests. Vitest's expectTypeOf checks that a public type still means what consumers expect, which is the part ordinary unit tests cannot see.

11. A tsconfig for 2026

This is a pragmatic application config. It assumes a bundler or framework emits the JavaScript, and it leaves skipLibCheck on because third-party declaration files are not your code:

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["dom", "dom.iterable", "esnext"],
    "module": "esnext",
    "moduleResolution": "bundler",
    "jsx": "preserve",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,
    "noPropertyAccessFromIndexSignature": true,
    "verbatimModuleSyntax": true,
    "erasableSyntaxOnly": true,
    "isolatedModules": true,
    "skipLibCheck": true,
    "noEmit": true
  }
}

Two notes. target: ES2022 is safe for every browser and Node version worth supporting in 2026; raising it further is a deployment decision, not a type-safety one. And skipLibCheck hides errors inside node_modules, not in your own .d.ts files, so it does not weaken the checks above.

12. The checklist

  1. Are the extra strict flags on, or is there a written reason they are off?
  2. Does every exported function have an explicit return type?
  3. Are configuration objects checked with satisfies instead of asserted with as?
  4. Can any component or function be in a state that should not exist?
  5. Is every external value parsed at the boundary before it is trusted?
  6. Is there an any in the codebase, and does it need to be there?
  7. Are IDs that are easy to mix up branded?
  8. Does CI fail when the type check fails?

None of this is new. That is the point. TypeScript best practice in 2026 is less about clever types and more about placing a small number of honest checks where they matter: at the edges of the system, and in the states a program is allowed to be in. Everything else is detail the compiler can infer.

If you want to go deeper, the TypeScript handbook's chapter on narrowing (opens in a new tab) is still the clearest explanation of how the compiler thinks, and the release notes (opens in a new tab) are shorter than most posts written about them.

  • Filed under
  • TypeScript
  • JavaScript
  • Type safety
  • Best practices

Keep reading