TypeScript Best Practices for 2026
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 | undefinedIf 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
| Practice | Replaces |
|---|---|
strict plus the extra flags | Opt-in safety |
unknown at boundaries | any |
| Type guards | as and ! |
satisfies | Widening annotations |
| Discriminated unions | Objects full of optional flags |
Pick / Omit / ReturnType | Hand-copied duplicate types |
| Union literals | Numeric enums |
| Generics that flow | Generics as decoration |
| One parse function at the edge | Assertions 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.
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