TypeScript Best Practices for Large-Scale Applications: Type Safety, Domain Modeling, and Runtime Validation
TypeScript has evolved into a foundational language for modern web development. When configured and used effectively, it helps prevent entire categories of programming errors, supports safer refactoring, and turns complex domain models into machine-checked documentation.
However, adopting TypeScript does not automatically make an application type-safe. Large codebases can suffer from type erosion, where liberal use of any, unsafe type assertions, and missing runtime validation create a false sense of security while production errors continue to occur.
Building reliable TypeScript applications requires more than adding type annotations. It requires a deliberate approach to compiler configuration, data validation, domain modeling, error handling, and architectural consistency.
This guide explores practical TypeScript best practices for large-scale applications, with examples that engineering teams can apply to real production systems.
1. Establish a Strong Compiler Foundation
Type safety starts with tsconfig.json. A permissive compiler configuration can allow errors to pass through development that stricter settings would identify earlier.
For most production applications, enable strict type checking and consider additional safeguards based on your existing codebase.
Recommended compiler configuration
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noFallthroughCasesInSwitch": true
}
}The strict option enables a family of type-checking rules, including strictNullChecks, noImplicitAny, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, and noImplicitThis.
You generally do not need to repeat these individual flags when strict is already enabled. Keeping the configuration concise makes the project's safety requirements easier to understand.
Two additional options deserve particular attention.
Why noUncheckedIndexedAccess matters
Consider this example:
const users: string[] = ["Alice", "Bob"];
const thirdUser = users[2];
console.log(thirdUser.toUpperCase());Without noUncheckedIndexedAccess, TypeScript ordinarily treats thirdUser as a string, even though the requested array element does not exist at runtime.
With the option enabled, the inferred type becomes string | undefined, requiring the developer to handle the possibility of a missing element.
A safer implementation looks like this:
const users: string[] = ["Alice", "Bob"];
const thirdUser = users[2];
if (thirdUser !== undefined) {
console.log(thirdUser.toUpperCase());
}This option also improves safety when accessing index-signature objects. It does not eliminate every possible runtime error, but it makes potentially missing values more visible to the compiler.
Understanding exactOptionalPropertyTypes
Optional properties can have different meanings in application code.
interface UserUpdate {
displayName?: string;
}This commonly represents a property that may be omitted. With exactOptionalPropertyTypes enabled, explicitly assigning undefined is not automatically equivalent to omitting the property unless undefined is included in the property's declared type.
That distinction matters for update APIs, where an omitted field might mean "leave the existing value unchanged," while an explicit value could represent a deliberate update.
Practical recommendation: Enable stricter settings early in new projects. For an established codebase, introduce them incrementally, resolve the resulting errors, and verify that the changes do not alter intended application behavior.
2. Treat Runtime Validation as a Separate Responsibility
One of the most dangerous misconceptions in TypeScript development is assuming that a declared type validates external data.
TypeScript checks relationships between types during development and compilation. Its type annotations are generally erased from the emitted JavaScript, so they do not automatically validate incoming HTTP requests, database results, environment variables, or third-party API responses.
Consider this example:
interface UserResponse {
id: string;
email: string;
}
const user = responseData as UserResponse;The assertion tells the compiler to treat responseData as a UserResponse. It does not verify that the data actually contains a valid ID or email address.
If an external service returns an unexpected payload, the application can still fail at runtime.
Validate untrusted data with schemas
Schema-validation libraries such as Zod, Valibot, and ArkType can validate data at runtime while providing useful TypeScript types.
The following example uses Zod:
import { z } from "zod";
export const UserProfileSchema = z.object({
id: z.string().min(1),
email: z.string().email(),
role: z.enum(["ADMIN", "MEMBER", "GUEST"]),
metadata: z.record(z.string(), z.unknown()).optional()
});
export type UserProfile = z.infer<typeof UserProfileSchema>;
export function parseUserProfile(
rawInput: unknown
): UserProfile {
const result = UserProfileSchema.safeParse(rawInput);
if (!result.success) {
throw new Error("Invalid user profile payload");
}
return result.data;
}This example establishes two complementary guarantees:
Compile-time safety:
UserProfileis inferred from the schema, reducing the need to maintain a separate type definition.Runtime validation:
safeParse()checks the actual input and returns either validated data or validation errors.
The schema can be reused at appropriate boundaries, including API handlers, webhook endpoints, and integrations with external services.
For an API that needs to return structured validation errors, handle result.error explicitly rather than replacing the details with a generic error.
Where validation belongs
Runtime validation is especially valuable at system boundaries:
HTTP request bodies and query parameters
Third-party API responses
Webhook payloads
Environment variables and application configuration
Imported files and user-supplied JSON
Data crossing between independently deployed services
Database results also deserve attention when the application relies on assumptions that the database client does not fully guarantee. Validation should complement database constraints, not replace them.
Important: A valid schema does not prove that a user is authorized to perform an operation. Structural validation, authentication, and authorization are separate responsibilities.
3. Model Application States with Discriminated Unions
Large applications frequently represent asynchronous operations using several boolean flags and optional properties.
This approach can produce contradictory states that are difficult to reason about and easy to mishandle.
The problem with independent flags
interface AsyncData<T> {
isLoading: boolean;
isError: boolean;
data?: T;
error?: Error;
}Nothing in this interface prevents the application from representing a request as loading and failed at the same time, or as successful without providing data.
The compiler cannot infer the intended relationship between these properties.
Use discriminated unions instead
A discriminated union represents each meaningful state as a distinct variant:
type AsyncState<T> =
| { status: "IDLE" }
| { status: "LOADING" }
| { status: "SUCCESS"; data: T }
| { status: "ERROR"; error: Error };
function renderUserState(
state: AsyncState<UserProfile>
): string {
switch (state.status) {
case "IDLE":
return "Ready";
case "LOADING":
return "Loading user";
case "SUCCESS":
return `Loaded ${state.data.email}`;
case "ERROR":
return `Failed: ${state.error.message}`;
}
}The status property acts as the discriminant. Once TypeScript narrows the state to SUCCESS, it knows that data exists. In the ERROR branch, it knows that error exists.
This pattern is useful for:
API request lifecycles
Authentication and authorization states
Payment and subscription workflows
Multi-step forms
Background jobs
Domain processes with explicit transitions
It also makes code reviews easier because the valid states are visible in the type definition.
Enforce exhaustive handling
As a domain model grows, a new state may be introduced. Existing code should not silently ignore that change.
An exhaustive-checking helper makes missing cases visible:
function assertNever(value: never): never {
throw new Error(
`Unhandled state: ${JSON.stringify(value)}`
);
}
function getStateLabel<T>(
state: AsyncState<T>
): string {
switch (state.status) {
case "IDLE":
return "Idle";
case "LOADING":
return "Loading";
case "SUCCESS":
return "Success";
case "ERROR":
return "Error";
default:
return assertNever(state);
}
}If a new variant is added to AsyncState<T> and the switch statement does not handle it, TypeScript reports a type error at the exhaustive check.
The runtime exception is a fallback for unexpected execution paths. The primary benefit is catching omissions during development.
4. Prefer unknown Over Unsafe any
The any type effectively opts out of TypeScript's type checking for the affected value. It can also allow unsafe assumptions to spread through function calls, property access, and assignments.
For example:
function processPayload(payload: any) {
return payload.user.profile.email.toLowerCase();
}This function compiles even when payload is null, user is missing, or email is a number.
When the shape of a value is not known, prefer unknown:
function processPayload(payload: unknown): string {
if (
typeof payload !== "object" ||
payload === null ||
!("user" in payload)
) {
throw new Error("Invalid payload");
}
const user = payload.user;
if (
typeof user !== "object" ||
user === null ||
!("profile" in user)
) {
throw new Error("Invalid user object");
}
const profile = user.profile;
if (
typeof profile !== "object" ||
profile === null ||
!("email" in profile) ||
typeof profile.email !== "string"
) {
throw new Error("Invalid email");
}
return profile.email.toLowerCase();
}This example uses explicit narrowing to demonstrate the underlying principle. For complex payloads, a schema validator is generally easier to maintain.
Handle errors without assuming their shape
JavaScript permits throwing values that are not Error instances. When handling failures, avoid assuming that every caught value has a message property.
try {
await executeCriticalOperation();
} catch (error: unknown) {
if (error instanceof Error) {
logger.error("Operation failed", {
message: error.message,
stack: error.stack
});
} else {
logger.error("Operation failed with an unknown error", {
error
});
}
}With modern TypeScript strict settings, catch variables default to unknown. Explicitly writing unknown can still make the intended handling clear to readers.
In production systems, error logs should also avoid exposing credentials, tokens, personal information, or sensitive request payloads.
Introduce an explicit policy for any
Completely eliminating any may be impractical in a mature application with legacy dependencies. The more sustainable goal is to prevent uncontrolled use.
Useful practices include:
Enable relevant type-aware ESLint rules.
Require justification for unavoidable unsafe assertions.
Use
unknownat untrusted boundaries.Isolate third-party libraries with incomplete typings behind small adapters.
Review new
anyusage during code review.Replace unsafe casts with validation or type narrowing where feasible.
The objective is not zero casts at any cost. It is making unsafe assumptions deliberate, local, and reviewable.
5. Design Maintainable Types for Large Codebases
Type safety becomes more valuable when the codebase has clear module boundaries and consistent conventions. Poorly organized types can create unnecessary coupling even when the compiler reports no errors.
Choose type and interface based on the model
Neither type nor interface is universally superior.
Use interfaces when describing object contracts that benefit from extension or declaration merging:
interface UserSummary {
id: string;
email: string;
}
interface AdminSummary extends UserSummary {
permissions: string[];
}Use type aliases when expressing unions, intersections, tuples, and other type compositions:
type UserRole = "ADMIN" | "MEMBER" | "GUEST";
type ApiResult<T> =
| { success: true; data: T }
| { success: false; error: string };
type Coordinate = [latitude: number, longitude: number];Choose a consistent convention for your project, document the exceptions, and optimize for readability rather than stylistic arguments.
Declare return types at important boundaries
Type inference is useful and should not be disabled indiscriminately. However, explicit return types can help stabilize public interfaces and service-layer contracts.
async function getUserProfile(
userId: string
): Promise<UserProfile | null> {
const user = await userRepository.findById(userId);
if (!user) {
return null;
}
return parseUserProfile(user);
}The declared return type makes the function's contract clear to callers and helps catch accidental changes during refactoring.
Use explicit return types particularly for exported functions, public libraries, service methods, and complex functions whose inferred types are difficult to understand.
Avoid unnecessary barrel-file complexity
Barrel files, commonly named index.ts, can simplify imports and create convenient public module interfaces. They are not inherently problematic.
However, very large barrel files and circular re-exports can complicate dependency graphs, contribute to circular-import bugs, and increase the work required by some build tools.
Prefer clear module boundaries, avoid circular dependencies, and expose only the intended public API of a module. Use direct imports when they improve clarity or help avoid problematic dependency cycles.
The goal is a predictable dependency structure, not eliminating every barrel file.
6. Separate Domain Types From External Data Contracts
A common source of type erosion is treating every representation of an entity as the same type.
A user object returned by an API, a database record, and a domain-level user model may look similar, but they serve different purposes.
For example, an external API might provide:
type ApiUser = {
id: string;
email: string;
role: string;
};The domain layer may require a narrower role type:
type UserRole = "ADMIN" | "MEMBER" | "GUEST";
type DomainUser = {
id: string;
email: string;
role: UserRole;
};Do not simply assert that an arbitrary API role is a UserRole. Validate and map the external representation first.
const UserRoleSchema = z.enum([
"ADMIN",
"MEMBER",
"GUEST"
]);
function toDomainUser(apiUser: ApiUser): DomainUser {
return {
id: apiUser.id,
email: apiUser.email,
role: UserRoleSchema.parse(apiUser.role)
};
}This makes the boundary explicit. If the upstream service introduces a new role, the application can handle the change intentionally instead of allowing an unexpected string to enter the domain layer.
This separation is especially useful when working with database-generated types, external APIs, legacy systems, or multiple services that evolve independently.
7. Make Type Safety Part of the Engineering Workflow
A strict tsconfig.json is only one part of a reliable development process. Large teams also need repeatable checks that run before changes reach production.
Build a practical verification pipeline
A typical CI pipeline should include:
Type checking: Run
tsc --noEmitto verify the TypeScript program without emitting JavaScript.Linting: Enforce agreed-upon rules for unsafe types, imports, and common mistakes.
Unit tests: Test domain logic, transformations, and edge cases.
Integration tests: Verify database, API, and service interactions.
Build verification: Ensure the production build completes successfully.
Boundary tests: Verify that malformed inputs and unexpected external responses are rejected correctly.
For example, a project can expose separate package scripts:
{
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"test": "vitest run",
"build": "next build"
}
}Adapt these commands to the framework and test runner used by the project.
A successful type check does not prove that the application is correct. Tests and runtime validation cover different failure modes, so all three should work together.
Improve legacy code incrementally
For an established codebase, enabling strict checks across the entire project can reveal hundreds of existing issues. A large, undifferentiated cleanup can delay useful feature work.
A more manageable approach is to:
Record the current type-checking and linting baseline.
Prevent new unsafe patterns from being introduced.
Prioritize authentication, authorization, billing, and other sensitive business logic.
Add runtime validation to high-risk external boundaries.
Refactor legacy modules in small, testable increments.
Tighten compiler settings as the codebase improves.
This approach makes progress measurable without requiring a complete rewrite.
Conclusion: Type Safety Is an Engineering Practice
TypeScript provides powerful tools for building maintainable applications, but those tools work best when supported by deliberate architectural decisions.
Strict compiler settings make assumptions visible. Runtime schemas validate untrusted data. Discriminated unions represent valid application states. unknown prevents unchecked access to values of uncertain shape. Clear module contracts and automated checks help teams maintain these guarantees as their systems grow.
The most important principle is to respect the boundary between what the compiler can prove and what the application must verify at runtime.
At Aformix, we build our own software products and also take on client work, including custom web development and software and SaaS engineering. Reliable type modeling, clear contracts, and disciplined validation are important considerations when building applications that need to evolve without becoming difficult to maintain.
For teams planning a new product or improving an existing codebase, the best starting point is usually straightforward: strengthen the compiler configuration, validate critical boundaries, model important domain states explicitly, and make type checking part of the regular development workflow.
Related reading: API Design Principles for Modern Web Applications

