TypeScript Best Practices for 2026

October 1, 202611 min read
Tags:
TypeScriptBest Practices

Most lists of TypeScript best practices are really style guides in disguise. They tell you where to put your semicolons and then stop. The practices below are the ones that change what the compiler can catch for you, which is the only reason you are paying the TypeScript tax in the first place.

Nine of them. Each one shows the version you probably have in your codebase right now and the version that does more work for you. All of it is TypeScript 5.x, and none of it needs a library.

A note on how to use this list. Three of these are configuration changes you make once and forget. The other six are habits, and habits only stick if you understand the failure they prevent. So each section leads with the bug, not the rule.

1. Turn on strict and leave it on

Everything else on this list assumes strict mode. Without it, null and undefined are assignable to everything, parameters silently become any, and most of what follows stops working.

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "verbatimModuleSyntax": true
  }
}

strict is a bundle of about eight flags. The three underneath it are not in the bundle and are worth adding on a new project: noUncheckedIndexedAccess makes array[0] return T | undefined like it always should have, exactOptionalPropertyTypes stops { name?: string } from accepting an explicit undefined, and verbatimModuleSyntax keeps your import statements honest about what is a type.

On an existing codebase, turn them on one at a time and fix the fallout before moving to the next. noUncheckedIndexedAccess in particular will light up hundreds of lines on a large project, which is not the flag being noisy — that is the number of places where you assumed an array lookup succeeded. The full list of what each flag does is in our tsconfig.json reference.

What you should not do is add // @ts-ignore to make the migration quiet. Use // @ts-expect-error instead: it fails the build once the underlying problem is fixed, so the comment cleans itself up rather than rotting in place for three years.

2. Use unknown at the boundary, never any

any does not mean "I do not know the type". It means "stop checking this value, and everything derived from it, forever". That is a much bigger promise than people think they are making.

// ❌ every property access below is unchecked, including the typo
function readConfigLoose(raw: any) {
  return raw.server.prot
}

unknown says the same thing honestly: the value exists, and you have to prove something about it before you can touch it.

// ✅ the compiler makes you earn the property access
function readConfigSafe(raw: unknown): number {
  if (
    typeof raw === 'object' &&
    raw !== null &&
    'port' in raw &&
    typeof raw.port === 'number'
  ) {
    return raw.port
  }
  throw new Error('config.port is missing or not a number')
}

The rule of thumb: any is a hole in the type system that you cannot see from the outside. unknown is a hole with a fence around it.

The practical move is to ban any with a lint rule and then look at what breaks. Most of the hits will be in three places: third-party code without types, catch (error) blocks, and the return of JSON.parse. All three are genuinely unknown values, which is exactly the type they should have.

3. Narrow instead of asserting

A type assertion is a promise to the compiler that nothing verifies. If the promise is wrong, you find out in production, usually as Cannot read properties of undefined.

type ApiUser = { id: string; email: string }
 
// ❌ nothing here checks anything at runtime
function greetAsserted(input: unknown) {
  const asserted = input as ApiUser
  return `Hi ${asserted.email}`
}

A type guard is the same check, except it actually runs.

function isApiUser(input: unknown): input is ApiUser {
  return (
    typeof input === 'object' &&
    input !== null &&
    typeof (input as ApiUser).id === 'string' &&
    typeof (input as ApiUser).email === 'string'
  )
}
 
// ✅ the type and the runtime behaviour agree
function greetGuarded(input: unknown) {
  if (!isApiUser(input)) return 'Hi stranger'
  return `Hi ${input.email}`
}

The same applies to the non-null assertion !. Every value! in your codebase is a place where you decided a crash was acceptable. Sometimes it is. Usually an if is cheaper than the incident.

There are two assertions worth keeping. as const is not really an assertion — it narrows rather than lies. And a cast inside a type guard, like the one above, is fine precisely because the guard's return type is what the rest of the program sees. The assertion is contained to four lines you can read in one sitting, instead of spread across every call site.

4. Validate with satisfies instead of annotating

This is the practice people miss most often, because the annotated version looks correct.

type RouteConfig = Record<string, { path: string; auth: boolean }>
 
// ❌ the annotation checks the shape but throws away the keys
const routesAnnotated: RouteConfig = {
  home: { path: '/', auth: false },
  billing: { path: '/billing', auth: true },
}

routesAnnotated.anything now type-checks, because the annotation widened the object to its declared type. The specific keys you wrote are gone.

// ✅ same validation, but the literal type survives
const routesChecked = {
  home: { path: '/', auth: false },
  billing: { path: '/billing', auth: true },
} satisfies RouteConfig
 
type RouteName = keyof typeof routesChecked // 'home' | 'billing'

You get the error when a route is malformed and a RouteName union you can use everywhere else. The full behaviour is covered in the satisfies operator reference.

5. Model states as a union, not as optional fields

Four optional booleans describe sixteen states. Your component handles three of them.

// ❌ loading and error at the same time? the type allows it
type RequestStateLoose = {
  loading?: boolean
  data?: string[]
  error?: Error
}

A discriminated union describes exactly the states that exist, and the compiler narrows the payload for you inside each branch.

// ✅ three states, and each one carries only what it needs
type RequestState =
  | { status: 'loading' }
  | { status: 'success'; data: string[] }
  | { status: 'error'; error: Error }
 
function renderRequest(state: RequestState) {
  switch (state.status) {
    case 'loading':
      return 'Loading…'
    case 'success':
      return state.data.join(', ')
    case 'error':
      return state.error.message
  }
}

No optional chaining, no data!, and adding a fourth state turns every incomplete switch in the codebase into a compile error. That last part is the whole point. See discriminated unions for the exhaustiveness patterns.

The tell that you need this refactor is a comment explaining which fields are set together, or a function that starts with three guard clauses to rule out combinations that should never have been expressible. When you find yourself writing documentation about your own type, the type is wrong.

6. Derive types, do not restate them

Every hand-copied type is a second source of truth, and second sources of truth drift.

type Product = {
  id: string
  title: string
  priceCents: number
  createdAt: Date
}
 
// ❌ rename a field on Product and this quietly keeps compiling
type ProductCardPropsManual = {
  id: string
  title: string
  priceCents: number
}
// ✅ these break the moment Product changes
type ProductCardProps = Pick<Product, 'id' | 'title' | 'priceCents'>
type ProductDraft = Omit<Product, 'id' | 'createdAt'>
type ProductPatch = Partial<ProductDraft>

Pick, Omit, Partial, ReturnType, Awaited — the utility types exist so that your types have the same dependency graph as your code.

The same idea applies past the utility types. If a value already exists, typeof value gives you its type for free, and keyof typeof gives you its keys. Deriving from a runtime constant means the type cannot disagree with the data, because there is only one of them.

7. Prefer union literals over enums

A numeric enum is two things at once: a type and a runtime object. You usually only wanted the type.

// ❌ ships JavaScript, and LogLevelEnum.Debug is 0, which is falsy
enum LogLevelEnum {
  Debug,
  Info,
  Error,
}
// ✅ erases completely, and the call site reads like the value it is
type LogLevel = 'debug' | 'info' | 'error'
 
const LOG_LEVELS = ['debug', 'info', 'error'] as const
type LogLevelFromArray = (typeof LOG_LEVELS)[number]

The as const array gives you both a runtime list to iterate and the union type, derived from it. There are still good reasons to use an enum — a stable wire format is one — and the trade-offs are laid out in the enums reference.

8. Add a generic only when a type flows through

A type parameter used in exactly one place is a longer way to write the constraint.

// ❌ T is never used in the return type, so it buys nothing
function firstKeyOf<T extends Record<string, unknown>>(record: T): string {
  return Object.keys(record)[0] ?? ''
}

A generic earns its keep when the caller's type travels from the input to the output.

// ✅ the element type survives the call
function firstItem<Item>(items: readonly Item[]): Item | undefined {
  return items[0]
}
 
const firstLabel = firstItem(['a', 'b']) // string | undefined
const firstCount = firstItem([1, 2, 3]) // number | undefined

If you can delete the type parameter and replace it with its constraint without changing a single call site, it was not doing anything. Generics are for relationships between types, not for decoration.

9. Parse untrusted input once, at the edge

Your types are a compile-time fiction about runtime data. The one place that fiction gets checked is wherever data enters the program: fetch responses, JSON.parse, process.env, form bodies, query params.

type Settings = { theme: 'light' | 'dark'; pageSize: number }
 
function parseSettings(raw: unknown): Settings {
  if (typeof raw !== 'object' || raw === null) {
    throw new Error('settings must be an object')
  }
  const { theme, pageSize } = raw as Partial<Settings>
  if (theme !== 'light' && theme !== 'dark') {
    throw new Error(`unknown theme: ${String(theme)}`)
  }
  if (typeof pageSize !== 'number') {
    throw new Error('pageSize must be a number')
  }
  return { theme, pageSize }
}

Do it once, in one function, and let everything downstream work with Settings instead of unknown. A schema library like Zod or Valibot does the same job with less typing, but the shape of the practice does not change: validate at the boundary, trust the types inside.

The anti-pattern is the typed fetch wrapper that nobody reads twice: const user = await res.json() as User. That line looks like type safety and provides none — it is assertion number three from earlier, wearing a helpful-looking generic. If the API drops a field, your types keep claiming it is there until something downstream reads undefined and falls over.

TypeScript best practices at a glance

PracticeReplaces
strict plus the extra flagsOpt-in safety
unknown at boundariesany
Type guardsas and !
satisfiesWidening annotations
Discriminated unionsObjects full of optional flags
Pick / Omit / ReturnTypeHand-copied duplicate types
Union literalsNumeric enums
Generics that flowGenerics as decoration
One parse function at the edgeAssertions sprinkled around

The pattern underneath all nine is the same: make the illegal state unrepresentable rather than documenting it. A codebase that follows these is not harder to write. It is just one where the mistakes show up in your editor instead of in your error tracker.

If you are adopting them on an existing project, the order matters. Start with the compiler flags, since they tell you where the other problems are. Then ban any and fix the boundaries, because that is where bad data gets in. The modelling practices — unions, derived types, satisfies — come last, one module at a time, usually while you are in there for another reason anyway. Trying to do all nine in one pull request is how type-safety migrations get abandoned.

Share this article

Become a TypeScript Pro

Track your progress through 200+ hands-on challenges. Free, sign in with GitHub.

Or start solving right away: explore all TypeScript challenges

Related Articles

TypeScript 7 is out - the native Go port and what changed

TypeScript 7.0 shipped as a native Go port of the compiler. What changed: performance, parallelism flags, tsconfig defaults, removed options, API caveats.

TypeScript 7 (Go Rewrite) - Current Status and Progress

Track the progress of TypeScript 7, the Go-based rewrite of the compiler. See what's shipping, performance benchmarks, and what it means for your projects.

TypeScript 5.9 - What's new and what you need to know

Discover what's new in TypeScript 5.9: import defer, thinner tsconfig, better DOM APIs, and performance boosts. Covers breaking changes and upgrades.

Practice what you read

Reading only gets you so far. Solve hands-on TypeScript challenges or brush up on the fundamentals in the TypeScript concept guides.