Application Design and TypeScript Best Practices
Apply types to API contracts, domain models, reusable libraries, error boundaries, and maintainable application architecture.
Domain Types and API Contracts
Separate domain models from transport DTOs when their responsibilities differ. A database entity may contain internal fields, while a public API response should expose only approved data. Use explicit conversion functions at boundaries so transformations are visible and testable.
Avoid assuming that a type alias used by both server and client automatically guarantees wire compatibility. Types can drift, and JSON cannot represent every JavaScript value (such as Date objects) in the same way. Document serialized formats and validate incoming data.
type UserEntity = {
id: string;
email: string;
passwordHash: string;
createdAt: Date;
};
type UserResponse = {
id: string;
email: string;
createdAt: string;
};
function toUserResponse(user: UserEntity): UserResponse {
return {
id: user.id,
email: user.email,
createdAt: user.createdAt.toISOString()
};
}- Separate internal entities from public DTOs when fields differ.
- Map and serialize values deliberately.
- Never expose sensitive internal fields just because they exist on a shared type.
Why map a database entity to a separate API response type?
Composition and Dependency Boundaries
Types make dependencies explicit. A service can depend on an interface-shaped capability instead of a concrete database or network implementation, making it easier to test and swap infrastructure. TypeScript interfaces are erased at runtime, however, so dependency injection needs runtime tokens, factory functions, or concrete values depending on the framework.
Use small interfaces that describe what a consumer needs rather than mirroring an entire library. This reduces coupling and makes unit tests simpler. Do not create abstractions solely to satisfy a type-system pattern; the boundary should represent a meaningful responsibility.
interface UserRepository {
findById(id: string): Promise<{ id: string; name: string } | null>;
}
class UserService {
constructor(private readonly users: UserRepository) {}
async displayName(id: string): Promise<string> {
const user = await this.users.findById(id);
return user ? user.name : "Unknown user";
}
}UserService depends on the repository contract rather than a specific database. Tests can provide an in-memory implementation with the same method signature.- Depend on the smallest useful capability.
- TypeScript interfaces do not exist as runtime injection tokens.
- Abstractions are valuable when they reduce real coupling.
What is a primary benefit of typing a service against a repository interface?
Avoiding any, Understanding never, and Assertions
any opts out of checking and can spread unsafety through a codebase. unknown is a safer top type. never represents an impossible value, often appearing when a function always throws or when a union has been exhaustively narrowed. Type assertions such as value as User tell the compiler to trust you; they do not convert or validate the value.
Use assertions only when you have evidence the compiler cannot express, such as knowledge from a validated boundary or a DOM lookup with a known element type. Keep assertions local, and avoid double assertions such as as unknown as X except in carefully justified interop code.
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle": return Math.PI * shape.radius ** 2;
case "square": return shape.side ** 2;
default: {
const impossible: never = shape;
return impossible;
}
}
}shape should be never. If another shape is added and not handled, the assignment reports an error.anydisables checks;unknownrequires narrowing.neveris useful for impossible states and exhaustiveness.- Assertions change the compiler's view, not the runtime value.
Does `value as User` validate the value at runtime?