TypeScript Best Practices
Core Principles
TypeScript 6.0 should improve the design quality of a codebase, not merely add annotations. In production systems, the goal is to make invalid states hard to represent, make refactoring cheaper, and keep runtime behavior aligned with static assumptions.
A durable TypeScript style usually follows five principles:
- Model the domain first. Types should reflect business invariants, not UI convenience or transport noise.
- Push uncertainty to the edges. Treat inputs from HTTP, databases, queues, the filesystem, and third-party packages as unknown until validated.
- Prefer inference over ceremony. Let the compiler infer local details; write explicit types at boundaries and for important abstractions.
- Optimize for maintainability, not cleverness. Advanced type features are valuable when they reduce bugs and duplication, but harmful when they obscure intent.
- Keep compile-time and runtime contracts in sync. Static types do not validate production payloads; schema validation and tests still matter.
Installation and Versioning
Target the latest stable TypeScript 6.0.x as a project dependency, not a global tool. Pin the version in devDependencies, and upgrade deliberately with release-note review.
npm install -D typescript@~6.0.0
npx tsc --versionRecommended versioning practices:
- Use a tilde range such as
~6.0.0for predictable patch updates in production repositories. - Upgrade TypeScript independently from framework upgrades so regressions are easier to isolate.
- Align editor, CI, and local CLI versions. A mismatch between VS Code’s language service and CI is a common source of confusion.
- Record compiler upgrades in the changelog for shared libraries and platform repos.
- For monorepos, centralize the TypeScript version unless there is a hard compatibility reason not to.
If you maintain libraries, test against both your pinned compiler and the lowest supported consumer environment.
tsconfig Strategy
A production repository should treat tsconfig as architecture, not boilerplate. Most teams benefit from a layered setup:
tsconfig.base.json: shared defaultstsconfig.json: local editor experience and referencestsconfig.build.json: emit-focused production buildtsconfig.test.json: test-specific overrides when needed
A strong baseline for Node.js ESM projects:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2023"],
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"useUnknownInCatchVariables": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"importsNotUsedAsValues": "error",
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": false,
"declaration": true,
"declarationMap": true,
"sourceMap": true
}
}Guidance:
- Default to
skipLibCheck: falsefor libraries and platform code where type integrity matters. Relax only with a documented reason. - Use project references in monorepos or large applications to scale type checking.
- Separate no-emit checking from production emit if your build tool is not
tsc. - Keep environment-specific types explicit: browser, Node.js, workers, and tests should not leak globals into each other.
Strictness Profile
"strict": true is the floor, not the ceiling. High-signal teams usually enable additional checks because they catch the kinds of defects that survive code review.
Prioritize these flags:
noUncheckedIndexedAccess: forces you to handle missing dictionary and array entries.exactOptionalPropertyTypes: distinguishes omission from explicitundefined.noImplicitOverride: protects inheritance-based code during refactors.useUnknownInCatchVariables: prevents accidental assumptions about thrown values.noFallthroughCasesInSwitch: reduces subtle state bugs.noPropertyAccessFromIndexSignature: useful when modeling untrusted maps.
Adoption rule: if a strict flag produces too much noise, do not disable it globally first. Measure the classes of issues it exposes, then decide whether to fix incrementally or isolate exceptions.
Type Design
Good type design starts with meaningful shapes and small, composable abstractions.
Prefer:
- domain-focused aliases such as
UserId,OrderStatus,IsoTimestamp - discriminated unions for state machines and workflow results
- readonly data structures by default for immutable flows
- explicit nullability rather than ambiguous optional chains everywhere
Avoid:
- giant “god interfaces” reused across API, database, cache, and UI layers
- encoding every runtime rule in exotic conditional types
- broad
Record<string, unknown>placeholders that never become concrete models
Example: use separate types for create, persistence, and view concerns.
type UserId = string & { readonly brand: unique symbol };
type CreateUserInput = {
email: string;
displayName: string;
};
type UserRecord = {
id: UserId;
email: string;
displayName: string;
createdAt: string;
deletedAt: string | null;
};
type UserSummary = Pick<UserRecord, "id" | "displayName">;unknown vs any
Use any only as a deliberate escape hatch with a comment or narrow wrapper. In most production code, unknown is the correct default for uncertain data.
anydisables type safety and contaminates downstream inference.unknownforces narrowing before use.neverdocuments impossible states and helps exhaustiveness checking.
function parseJson(text: string): unknown {
return JSON.parse(text);
}
function isFeatureFlagMap(value: unknown): value is Record<string, boolean> {
return typeof value === "object" && value !== null &&
Object.values(value).every((item) => typeof item === "boolean");
}When interoperability forces any—for example around legacy plugins—contain it in a single adapter module.
Unions and Intersections
Unions are ideal for modeling alternatives; intersections are best for composition. In production systems, discriminated unions are often the clearest way to encode workflow states.
type PaymentResult =
| { kind: "authorized"; authorizationId: string }
| { kind: "declined"; reason: string }
| { kind: "failed"; retryable: boolean };
function handlePayment(result: PaymentResult): string {
switch (result.kind) {
case "authorized":
return result.authorizationId;
case "declined":
return result.reason;
case "failed":
return result.retryable ? "retry" : "stop";
default: {
const exhaustive: never = result;
return exhaustive;
}
}
}Use intersections carefully. They work well when composing orthogonal capabilities such as timestamps, auditing, or pagination metadata. They are less effective when combining incompatible object shapes and hoping the result becomes meaningful.
Generics
Generics should preserve relationships between inputs and outputs. If they do not encode a real relationship, they are usually unnecessary.
Good uses of generics:
- collection helpers
- repository abstractions
- reusable async result wrappers
- schema-driven utilities
type Page<T> = {
items: T[];
nextCursor: string | null;
};
async function fetchPage<TDto>(url: string): Promise<Page<TDto>> {
const response = await fetch(url);
return response.json() as Promise<Page<TDto>>;
}Guidelines:
- Prefer descriptive names like
TItem,TError,TContextwhen a single-letter name reduces clarity. - Constrain generics when the implementation depends on specific properties.
- Avoid generic APIs that require callers to manually specify type arguments most of the time; inference should do the work.
- Be suspicious of multi-layer conditional generics in app code unless they eliminate significant duplication.
satisfies
Use satisfies when you want to validate that a value conforms to a type without widening away useful literal information.
type RouteConfig = {
path: `/${string}`;
cache: "public" | "private";
timeoutMs: number;
};
const routes = {
health: { path: "/health", cache: "private", timeoutMs: 1_000 },
docs: { path: "/docs", cache: "public", timeoutMs: 5_000 }
} satisfies Record<string, RouteConfig>;This is especially useful for:
- configuration objects
- translation dictionaries
- feature flag catalogs
- route maps
- test fixtures with precise literals
Prefer satisfies over broad as assertions whenever you want checking plus precise inference.
Template Literal Types
Template literal types are powerful for structured strings, but production use should stay pragmatic.
Effective use cases:
- route patterns
- event names
- analytics keys
- environment-variable naming conventions
- CSS token or design-system identifiers
type Entity = "user" | "invoice";
type Action = "created" | "deleted";
type DomainEvent = `${Entity}.${Action}`;Do not build type-level parsers for every string protocol. If runtime parsing is non-trivial, prefer validation code plus narrower branded results.
Utility Types
Built-in utility types are foundational, but they are often misused as shortcuts instead of deliberate models.
Use them to derive targeted views:
PickandOmitfor focused projectionsPartialfor patch semantics, not for “I do not know the shape yet”Readonlyfor immutable APIsRecordfor known key spacesExclude/Extractfor union refinementReturnType,Parameters, andAwaitedfor integration layers
Example:
type User = {
id: string;
email: string;
displayName: string;
role: "admin" | "member";
};
type UserPatch = Partial<Pick<User, "displayName" | "role">>;
type PublicUser = Omit<User, "email">;If utility-type chains become unreadable, stop and name the intermediate concept.
Module Boundaries
Most TypeScript defects in mature systems happen at boundaries, not inside tiny pure functions.
Best practices:
- Export domain types and public interfaces, not internal helper types by default.
- Keep file-level responsibilities narrow.
- Avoid deep imports across feature boundaries.
- Use
import typefor type-only dependencies to keep intent clear and ESM output clean. - Prefer named exports over default exports in shared libraries for refactorability and discoverability.
A healthy boundary often looks like this:
- external data enters as
unknown - validator converts it into a stable DTO
- mapper transforms DTO into domain objects
- application code consumes domain types only
Runtime Validation
TypeScript does not validate runtime input. Any data crossing a trust boundary must be parsed or validated.
Typical trust boundaries:
- HTTP requests and responses
- database rows
- message queues
- environment variables
- CMS content
- local storage or cookies
- third-party SDK callbacks
Use a schema library such as Zod, Valibot, ArkType, or a JSON Schema pipeline, depending on your stack.
import { z } from "zod";
const UserDtoSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
displayName: z.string().min(1)
});
type UserDto = z.infer<typeof UserDtoSchema>;
function parseUserDto(value: unknown): UserDto {
return UserDtoSchema.parse(value);
}Static typing without runtime validation creates false confidence. Validation without typed outputs creates repetition. Production systems need both.
API and Client DTOs
Never reuse one type for every layer of a distributed system. DTOs exist because transport concerns differ from domain concerns.
Separate at least these concepts when complexity justifies it:
- request DTOs
- response DTOs
- domain entities
- persistence records
- UI view models
Recommendations:
- Treat API responses as validated DTOs first.
- Map DTOs into domain types explicitly.
- Version public API contracts intentionally.
- Do not leak database column names into frontend types.
- Prefer nullable fields over optional fields when the API distinguishes “present but empty” from “not sent”.
This separation increases code volume slightly but dramatically reduces coupling during migrations.
NodeNext and ESM Guidance
For modern Node.js projects, prefer module: "NodeNext" and moduleResolution: "NodeNext" unless you have a hard constraint to stay on CommonJS.
Key rules:
- Use native ESM consistently if you choose it.
- Include explicit file extensions in relative imports when your runtime requires them.
- Prefer
verbatimModuleSyntax: trueto avoid surprising emit behavior. - Use
import typewhere appropriate. - Understand package boundary fields:
type,exports,imports, andtypes. - Avoid mixing CommonJS and ESM patterns casually in the same package.
For libraries:
- decide whether you ship ESM only or dual packages
- test the published package shape, not just source compilation
- generate declarations that match actual entry points
For applications, keep runtime semantics simple; complexity at module boundaries compounds quickly.
Build and Typecheck Workflows
Treat transpilation and type-checking as separate concerns unless tsc is your only build tool.
A common production setup:
- fast bundler/transpiler for build output (
tsup,esbuild,swc, Vite, framework compiler) tsc --noEmitfor authoritative type checkingtsc -p tsconfig.build.jsonwhen declarations or compiler-driven emit are required
Example scripts:
{
"scripts": {
"typecheck": "tsc --noEmit",
"build": "tsup src/index.ts --format esm --dts",
"build:types": "tsc -p tsconfig.build.json"
}
}In CI:
- install with a frozen lockfile
- run lint
- run typecheck
- run tests
- run production build
Do not rely on “it builds” as proof that types are sound.
Testing
TypeScript tests should verify runtime behavior and type contracts separately.
Recommended layers:
- unit tests for deterministic logic
- integration tests for I/O boundaries and adapters
- contract tests for API expectations
- type tests for public libraries and advanced type utilities
Useful practices:
- Run tests against compiled or realistic runtime paths when module behavior matters.
- Use
tsd,expect-type, orvitesttype assertions for exported API guarantees. - Avoid over-mocking. Mock only the boundary you do not want to execute.
- Keep fixtures typed with
satisfiesso they stay precise and maintainable.
Linting and Formatting
Use linting to catch semantic issues and formatting to remove style debate.
Recommended stack:
- ESLint
typescript-eslint- Prettier
- optionally
eslint-plugin-import,eslint-plugin-unicorn, or framework-specific rules
Focus lint rules on signal:
- ban unsafe
anyusage where practical - detect floating promises
- prevent misused async functions
- enforce consistent type imports
- catch unnecessary assertions and redundant conditions
Formatting should be automated and mostly uncontroversial. Do not encode formatting preferences as heavy lint rules if Prettier already owns them.
Performance
TypeScript performance matters in large codebases. Slow editor feedback reduces engineering quality.
Ways to keep the compiler healthy:
- use project references in monorepos
- avoid enormous union explosions and deeply recursive conditional types
- name complex intermediate types instead of recomputing them everywhere
- reduce unnecessary
.d.tssurface area in internal packages - profile pathological files before blaming the whole toolchain
- upgrade dependencies with poor type performance when possible
A useful heuristic: if a type takes a paragraph to explain, it may also take too much effort for humans and tooling to maintain.
Migration Strategy
Adopting stronger TypeScript practices in an existing codebase requires sequencing.
A pragmatic migration plan:
- pin a modern compiler version
- centralize
tsconfigand remove conflicting local overrides - enable
strict - fix boundary types around API, database, and config loading
- replace broad assertions with narrowing or validation
- enable high-value flags such as
noUncheckedIndexedAccess - split oversized shared types into layer-specific models
- add lint rules for unsafe escape hatches
For very large repositories, track progress by directory or package. Avoid a big-bang rewrite unless the codebase is already undergoing a platform reset.
Anti-Patterns
Common TypeScript anti-patterns in production:
asassertions used as a substitute for design- shared “entity” types reused across backend, frontend, storage, and tests
- exposing internal implementation types from package entry points
- defaulting to classes where plain objects and functions are simpler
- using enums when string literal unions are easier to evolve
- generic helpers that erase intent instead of clarifying it
- treating generated types as domain models without review
- turning on
skipLibCheckforever to hide dependency health issues - trusting
response.json() as SomeTypewithout validation
When in doubt, favor explicitness at boundaries and simplicity in the core.
Team Checklist
A production TypeScript team should be able to answer “yes” to most of the following:
- Is TypeScript pinned at a current stable 6.0.x version?
- Is
strictenabled across the repository? - Are additional strictness flags reviewed intentionally rather than left at defaults?
- Are external inputs treated as
unknownuntil validated? - Are DTOs, domain types, and persistence models separated where needed?
- Are
anyandasusage visible and justified? - Are module boundaries clear, with
import typeand named exports used consistently? - Does CI run lint, typecheck, tests, and production build separately?
- Are public APIs covered by type tests or contract tests?
- Is migration debt tracked rather than silently normalized?