Home / Articles / Ten TypeScript Habits That Keep Large Codebases Readable and Safe

This article is published in English.

Ten TypeScript Habits That Keep Large Codebases Readable and Safe

Learn ten practical TypeScript habits, from meaningful generics and narrowing to exhaustive checks, readonly and strict tsconfig, that keep growing codebases maintainable.

2557 words

Most TypeScript trouble in a growing codebase has nothing to do with not knowing what a generic or a conditional type is. It comes from everyday decisions: abstractions that try too hard, types that accept too much, as sprinkled everywhere, contracts defined twice, unreadable generic signatures, functions whose arguments mean nothing at the call site, types scattered across random files, and a compiler configured too loosely to catch what the team relies on it for. In a small project these habits barely register; across dozens of developers and a few years, they compound into debt. This guide walks through ten concrete practices that keep TypeScript code easy to read and change, plus a checklist you can apply during code review.

If you want the modeling side first, including how to make invalid states unrepresentable, start with modeling domains in TypeScript beyond basic annotations. Here the focus is on the maintenance habits that sit on top of good models.

1. Treat generics as a way to express relationships

Generics are usually introduced as a reuse mechanism, and they are one. Their more important job, though, is to connect types: to tell the compiler that what comes out of a function is tied to what went in. Here is the smallest useful example, a function that returns the first element of an array.

function getFirst<T>(items: T[]): T | undefined {
  return items[0]
}

The type parameter T is captured from the argument. Pass an array of users:

const users: User[] = [...]

and the compiler infers the result accordingly:

const user = getFirst(users)
// User | undefined

The same function works for a different element type without any extra annotation:

const products: Product[] = [...]

giving a correctly typed product result:

const product = getFirst(products)
// Product | undefined

Now look at what happens if you drop the generic and use unknown instead. The function still runs, but the link between input and output is gone, and every caller has to cast or narrow the result.

function getFirst(items: unknown[]): unknown {
  return items[0]
}

A good test before introducing a type parameter is to name the relationship it preserves. If you cannot say which input type determines which output type, the generic is probably not pulling its weight.

One detail worth noticing: with noUncheckedIndexedAccess enabled (covered in section 10), items[0] is typed as T | undefined by the compiler itself, which matches the explicit return type here.

2. Resist turning everything into a generic

Because generics are powerful, they are easy to overuse. It is tempting to write a signature with several constrained type parameters that depend on one another, like the sketch below. It feels sophisticated when you write it.

function processData<
  T extends Record<string, unknown>,
  K extends keyof T,
  R extends ...
>(...) {
  // ...
}

The developer who opens that file six months later tends to feel differently. Every extra type parameter is something the reader must hold in their head. If the function really only ever handles users, a plain signature communicates far more:

function processUser(user: User) {
  // ...
}

TypeScript expertise is not measured by how much of the type system you can fit into one declaration. Reach for a generic when it captures a real relationship between types, not merely because the language allows it.

3. Narrow values instead of casting them

A type assertion is the quickest way to silence a complaint:

const value = something as string

The problem is that as does not check anything. It tells the compiler to drop its uncertainty and believe you, and if you are wrong the error shows up at runtime instead. A safer approach is to prove the type with a runtime check the compiler understands:

if (typeof something === 'string') {
  console.log(something.toUpperCase())
}

Inside the if block, something is a string, because typeof is a narrowing construct. For object shapes, write a user-defined type guard. The return type value is User tells the compiler that a true result means the argument can be treated as a User.

function isUser(value: unknown): value is User {
  return (
    typeof value === 'object' &&
    value !== null &&
    'id' in value &&
    'name' in value
  )
}

Callers then get narrowing for free:

if (isUser(value)) {
  console.log(value.name)
}

The contrast is simple: an assertion asks the compiler to trust you, while narrowing supplies the evidence. Keep in mind that a type guard is only as honest as its body. The example checks that id and name exist but not what types they hold, so for data from the network or storage you may want stricter checks or a schema validator. The compiler trusts the guard's verdict completely.

4. Let never flag incomplete branching

Suppose a status is modeled as a union of string literals:

type Status =
  | 'pending'
  | 'approved'
  | 'rejected'

A switch that maps each status to a label looks complete:

function getLabel(status: Status) {
  switch (status) {
    case 'pending':
      return 'Pending'
    case 'approved':
      return 'Approved'
    case 'rejected':
      return 'Rejected'
  }
}

It is complete today. The trouble starts when the union grows, for example when someone adds a cancelled state:

type Status =
  | 'pending'
  | 'approved'
  | 'rejected'
  | 'cancelled'

Status may be used in dozens of places, and you want the compiler to point at every one of them that no longer covers all cases. The standard technique is an exhaustiveness helper that accepts never. In the default branch, TypeScript has already eliminated every handled member, so the remaining type should be never. If a new member slips through, it is not assignable to never and compilation fails.

function assertNever(value: never): never {
  throw new Error(`Unhandled value: ${value}`)
}

function getLabel(status: Status) {
  switch (status) {
    case 'pending':
      return 'Pending'
    case 'approved':
      return 'Approved'
    case 'rejected':
      return 'Rejected'
    default:
      return assertNever(status)
  }
}

After adding 'cancelled', the call to assertNever(status) becomes a compile error until you handle the new case. The union definition turns into the single source of truth, and the compiler produces the list of places to update. As a bonus, the throw protects you at runtime if an unexpected value arrives from outside the type system.

5. Use readonly to state how data should be used

Types describe which values are allowed, but they can also describe how those values may be handled. Marking a property readonly signals that it is fixed once the object exists:

type User = {
  readonly id: string
  name: string
}

An assignment such as the following is then rejected by the compiler:

user.id = '123'

Arrays can be protected in the same way. A parameter typed as readonly User[] lets the function iterate and read, but not push, splice or sort in place:

function processUsers(users: readonly User[]) {
  // ...
}

That signature tells every caller the function will not modify their collection. readonly is especially helpful for configuration objects, shared data, constants, function parameters and immutable state. The main benefit is less about blocking a particular mutation and more about documenting intent for everyone who reads the type. Note that readonly is shallow and compile-time only: nested objects remain mutable unless they are marked too, and nothing is frozen at runtime.

6. Do not hide real shapes behind Record<string, unknown>

Signatures like this are common:

function process(data: Record<string, unknown>) {
  // ...
}

Sometimes that is the correct type. If a function truly accepts arbitrary key-value data, such as a generic logger or a serialization helper, a broad record is honest. The problem is using it when you already know what the object is. Take the same signature:

function process(data: Record<string, unknown>) {
  // ...
}

and model the data you actually expect:

type User = {
  id: string
  name: string
}

function process(user: User) {
  // ...
}

The change looks cosmetic but the payoff is large: autocomplete, inline documentation, safe refactoring, compile-time guarantees and a clear statement of intent. Broad types belong at genuinely dynamic boundaries, like parsing unknown JSON, and should be narrowed into real types as soon as possible after that boundary rather than used as the default everywhere.

7. Design function APIs that explain themselves

Positional arguments become opaque quickly, especially booleans. Reading a call like this, you cannot tell what true and false control without opening the definition:

createUser(
  'Akshat',
  'akshat@example.com',
  true,
  false,
)

An options object puts the meaning at the call site:

createUser({
  name: 'Akshat',
  email: 'akshat@example.com',
  sendWelcomeEmail: true,
  isAdmin: false,
})

The function then declares a named type for its options:

type CreateUserOptions = {
  name: string
  email: string
  sendWelcomeEmail: boolean
  isAdmin: boolean
}

function createUser(options: CreateUserOptions) {
  // ...
}

The benefit grows with the number of parameters. Two arguments are usually fine positionally; seven almost always cause mistakes, especially when several share a type and can be swapped without any error. An options object also makes it easy to add optional fields later without breaking existing callers.

8. Keep types next to the domain they describe

Many projects start with one shared types.ts. It is convenient at first, then every developer adds to it, and a year later it holds hundreds of unrelated definitions. Finding the right type becomes a global search, and the file turns into a merge-conflict hotspot.

A better default is to colocate types with the domain code that owns them:

users/
  user.types.ts
  user.service.ts
  user.repository.ts

payments/
  payment.types.ts
  payment.service.ts
  payment.repository.ts

facilities/
  facility.types.ts
  facility.service.ts
  facility.repository.ts

The exact folder layout matters less than the rule behind it: a type lives with the domain it describes. If you know where the business logic for payments sits, you should be able to guess where the payment types sit too. Truly cross-cutting types, such as shared API envelopes, can still live in a small common module.

9. Keep the type system simpler than the business logic

TypeScript offers mapped types, conditional types, template literal types, recursive types, infer and distributive conditionals. With those tools you can build almost anything at the type level, which is exactly why restraint matters. Consider a helper like this, which filters an object down to keys ending in Id:

type Magic<T> =
  T extends infer U
    ? U extends Record<string, unknown>
      ? {
          [K in keyof U as K extends `${string}Id`
            ? K
            : never]: U[K]
        }
      : never
    : never

Type-level programming has legitimate uses, particularly in libraries. But there is a point where a type adds more complexity than it removes. If a teammate must decode an elaborate type before they can follow the business rule it supports, ask whether a simpler version would do. Sometimes the answer is no and the complexity is justified; often it is not. Cleverness is not quality. Boring, readable types usually beat impressive ones, and when an advanced type is truly needed, a short comment and a couple of type tests make it far easier to maintain.

10. Configure tsconfig deliberately

One of the easiest ways to weaken TypeScript is a configuration that ignores the very problems you expect it to catch. At a minimum, know what these options do:

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

strict turns on a family of checks, including strictNullChecks and noImplicitAny. The other two are separate opt-ins that strict does not enable: noUncheckedIndexedAccess adds undefined to indexed reads from arrays and records, and exactOptionalPropertyTypes distinguishes a property that is missing from one explicitly set to undefined. Both can surface many errors in an existing project.

The right combination depends on the codebase. A legacy project may not be able to switch everything on at once, and enabling flags gradually is perfectly reasonable. What matters is that the team knows what the compiler is and is not checking, starting with this one:

"strict": true

Strict mode is not there to make TypeScript tedious. It makes the compiler honest about uncertainty, which is the whole reason for using TypeScript: catching problems before users do.

Why these habits matter together

None of these practices is valuable as a trick. Their value is that they make the codebase easier to reason about. Picture a new teammate who comes across this type:

type Payment =
  | {
      status: 'SUCCESS'
      transactionId: string
    }
  | {
      status: 'FAILED'
      error: string
    }

Without reading any implementation, they learn a business rule: a successful payment carries a transaction ID, and a failed one carries an error. The type communicates how that part of the system behaves, not merely that some property is a string. That is the standard to aim for.

A code review checklist

Before committing TypeScript, run through these questions:

  • Could this any be unknown or a proper type?
  • Is this annotation repeating something the compiler already infers?
  • Do the types represent only valid domain states?
  • Is this property optional because it genuinely is, or out of convenience?
  • Would a union describe this state more precisely?
  • Is this as here because the value is provably safe, or just to make an error go away?
  • Does this generic express a real relationship between types?
  • Is the new abstraction easier to understand than the code it replaces?
  • Can a reader tell what each argument means at the call site?
  • Would readonly clarify ownership or immutability?
  • Will the compiler notice when this domain changes?
  • Can a teammate understand this type without decoding it?

The final question tends to matter most.

Wrapping up: types as a design tool

With experience, the syntax becomes the least interesting part of TypeScript. What counts is what you choose to express. You can describe an object that happens to contain some strings, or you can describe an operation that is always in one of four states, each guaranteeing a specific set of properties. The second is far more useful.

Good TypeScript is therefore not judged by how many advanced features a developer can recite, but by how well the type system helps a team understand, change and maintain the software. When the compiler enforces rules your application already depends on, types stop being a safety net and become part of the architecture.

  • Use generics for relationships, and plain signatures when there is no relationship to capture.
  • Prefer evidence (narrowing, guards, exhaustive checks) over assertions.
  • Let types document intent through readonly, precise shapes and options objects.
  • Keep types close to their domain and simpler than the logic they serve.
  • Know exactly what your tsconfig checks, and tighten it deliberately.