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.
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
anybeunknownor 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
ashere 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
readonlyclarify 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
tsconfigchecks, and tighten it deliberately.