TypeScript has become the de facto standard for large-scale JavaScript applications. Here are the best practices to write better type-safe code in 2025.
1. Use Strict Mode
Always enable strict mode in tsconfig.json:
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true
}
}
2. Prefer Interfaces for Object Shapes
Use interfaces for object shapes, types for unions/intersections:
// Good: Interface for object shape
interface User {
id: string;
name: string;
email: string;
}
// Good: Type for union
type Status = 'pending' | 'approved' | 'rejected';
// Good: Type for intersection
type AdminUser = User & { role: 'admin' };
3. Use Discriminated Unions
Make type narrowing easier with discriminated unions:
type Result<T> =
| { success: true; data: T }
| { success: false; error: string };
function handleResult<T>(result: Result<T>) {
if (result.success) {
// TypeScript knows result.data exists
console.log(result.data);
} else {
// TypeScript knows result.error exists
console.error(result.error);
}
}
4. Avoid any, Use unknown
Never use any, use unknown for truly unknown types:
// Bad
function processData(data: any) {
return data.value;
}
// Good
function processData(data: unknown) {
if (typeof data === 'object' && data !== null && 'value' in data) {
return (data as { value: string }).value;
}
throw new Error('Invalid data');
}
// Better: Use type guards
function isDataWithValue(data: unknown): data is { value: string } {
return typeof data === 'object' && data !== null && 'value' in data;
}
function processData(data: unknown) {
if (isDataWithValue(data)) {
return data.value; // Type-safe!
}
throw new Error('Invalid data');
}
5. Use const Assertions
Preserve literal types with as const:
// Without const assertion
const colors = ['red', 'green', 'blue']; // string[]
// With const assertion
const colors = ['red', 'green', 'blue'] as const; // readonly ["red", "green", "blue"]
// Extract union type
type Color = typeof colors[number]; // "red" | "green" | "blue"
6. Use Utility Types
Leverage built-in utility types:
interface User {
id: string;
name: string;
email: string;
password: string;
}
// Partial: Make all properties optional
type PartialUser = Partial<User>;
// Pick: Select specific properties
type PublicUser = Pick<User, 'id' | 'name' | 'email'>;
// Omit: Exclude specific properties
type UserWithoutPassword = Omit<User, 'password'>;
// Required: Make all properties required
type RequiredUser = Required<PartialUser>;
// Readonly: Make all properties readonly
type ReadonlyUser = Readonly<User>;
7. Use Generic Constraints
Constrain generics for type safety:
// Bad: Too permissive
function getValue<T>(obj: T, key: string) {
return obj[key]; // Error: can't index T with string
}
// Good: Constrained generic
function getValue<T extends Record<string, unknown>>(
obj: T,
key: keyof T
): T[keyof T] {
return obj[key];
}
// Usage
const user = { name: 'John', age: 30 };
const name = getValue(user, 'name'); // string
const age = getValue(user, 'age'); // number
8. Prefer Function Overloads for Complex Signatures
Use function overloads for better type inference:
// Function overloads
function format(value: string): string;
function format(value: number): string;
function format(value: Date): string;
function format(value: string | number | Date): string {
if (value instanceof Date) {
return value.toISOString();
}
return String(value);
}
// Usage
const str = format('hello'); // string
const num = format(42); // string
const date = format(new Date()); // string
9. Use Template Literal Types
Create powerful string types:
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type ApiEndpoint = `/api/${string}`;
type FullEndpoint = `${HttpMethod} ${ApiEndpoint}`;
// Usage
const endpoint: FullEndpoint = 'GET /api/users';
10. Use satisfies Operator (TypeScript 4.9+)
Ensure values match types without widening:
// Without satisfies
const config = {
apiUrl: 'https://api.example.com',
timeout: 5000,
}; // Type is inferred, might be too wide
// With satisfies
const config = {
apiUrl: 'https://api.example.com',
timeout: 5000,
} satisfies {
apiUrl: string;
timeout: number;
}; // Type is checked but not widened
11. Avoid Type Assertions
Prefer type guards over type assertions:
// Bad: Type assertion
function processUser(data: unknown) {
const user = data as User;
return user.name; // Unsafe!
}
// Good: Type guard
function isUser(data: unknown): data is User {
return (
typeof data === 'object' &&
data !== null &&
'id' in data &&
'name' in data &&
'email' in data
);
}
function processUser(data: unknown) {
if (isUser(data)) {
return data.name; // Type-safe!
}
throw new Error('Invalid user data');
}
12. Use branded Types for Nominal Typing
Create distinct types for the same underlying type:
type UserId = string & { readonly brand: unique symbol };
type ProductId = string & { readonly brand: unique symbol };
function createUserId(id: string): UserId {
return id as UserId;
}
function createProductId(id: string): ProductId {
return id as ProductId;
}
// Usage
const userId = createUserId('123');
const productId = createProductId('123');
// Type error: can't mix UserId and ProductId
function getUser(id: UserId) { /* ... */ }
getUser(productId); // Error!
13. Use const for Immutable Data
Prefer const for immutable references:
// Bad: Mutable array
let items = [1, 2, 3];
items.push(4); // Mutation allowed
// Good: Readonly array
const items = [1, 2, 3] as const;
// items.push(4); // Error: readonly
// Good: Readonly type
const items: readonly number[] = [1, 2, 3];
14. Use Index Signatures Carefully
Be specific with index signatures:
// Bad: Too permissive
interface Config {
[key: string]: any;
}
// Good: More specific
interface Config {
[key: string]: string | number | boolean;
}
// Better: Use Record utility type
type Config = Record<string, string | number | boolean>;
// Best: Be explicit when possible
interface Config {
apiUrl: string;
timeout: number;
retries: number;
[key: string]: string | number; // Only for truly dynamic keys
}
15. Use never for Exhaustiveness Checking
Ensure all cases are handled:
type Status = 'pending' | 'approved' | 'rejected';
function handleStatus(status: Status) {
switch (status) {
case 'pending':
return 'Processing...';
case 'approved':
return 'Approved!';
case 'rejected':
return 'Rejected.';
default:
// TypeScript ensures all cases are handled
const exhaustive: never = status;
return exhaustive;
}
}
16. Use Mapped Types for Transformations
Create new types from existing ones:
type Optional<T> = {
[K in keyof T]?: T[K];
};
type Nullable<T> = {
[K in keyof T]: T[K] | null;
};
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
17. Prefer Composition Over Inheritance
Use composition with types:
// Bad: Inheritance
class User extends BaseEntity {
// ...
}
// Good: Composition
type User = BaseEntity & {
name: string;
email: string;
};
18. Use readonly for Immutability
Mark properties as readonly when appropriate:
interface Config {
readonly apiUrl: string;
readonly timeout: number;
}
const config: Config = {
apiUrl: 'https://api.example.com',
timeout: 5000,
};
// config.apiUrl = '...'; // Error: readonly
19. Use as const with Enums
Prefer as const objects over enums:
// Bad: Enum
enum Status {
Pending = 'pending',
Approved = 'approved',
Rejected = 'rejected',
}
// Good: const object
const Status = {
Pending: 'pending',
Approved: 'approved',
Rejected: 'rejected',
} as const;
type Status = typeof Status[keyof typeof Status];
20. Document Complex Types
Add JSDoc comments for complex types:
/**
* Represents a user in the system.
*
* @property id - Unique identifier
* @property name - User's full name
* @property email - User's email address
* @property role - User's role in the system
*/
interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'user' | 'guest';
}
Tools and Resources
Type Checking
tsc --noEmit- Type check without emitting- ESLint with TypeScript rules
typescript-eslint- Enhanced linting
Type Utilities
type-fest- Useful type utilitiests-toolbelt- Type utilities libraryutility-types- TypeScript utility types
Testing
tsd- Type testing library@typescript-eslint/parser- ESLint parser
Conclusion
Following these best practices will help you:
- Write safer code: Catch errors at compile time
- Improve maintainability: Clear, self-documenting types
- Enhance developer experience: Better IDE support
- Reduce bugs: Type safety prevents common errors
Remember: TypeScript is a tool to help you write better JavaScript. Use it wisely, but don’t over-engineer. Sometimes, simpler is better.
Happy typing! 🎯