TypeScript has evolved from a niche tool to the default choice for serious JavaScript development. In 2025, the patterns and practices have matured significantly. This guide covers the essential patterns you should adopt for maintainable, type-safe code.

Why strict mode matters

Always enable strict mode in your tsconfig.json. It catches more bugs at compile time and forces better coding habits.

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true
  }
}
OptionWhat it catches
strictEnables all strict checks
noUncheckedIndexedAccessArray access might be undefined
noImplicitReturnsAll code paths must return
noFallthroughCasesInSwitchCatches missing breaks

Type layers architecture

Think of your types in layers. This creates clear boundaries and prevents leaky abstractions.

Layer responsibilities

  • DTO Layer - Validate and transform external data
  • Domain Layer - Core business types
  • UI Layer - Component-specific types

Utility types you should master

TypeScript provides powerful utility types. Here’s a reference for the most useful ones:

UtilityPurposeExample
Partial<T>Make all properties optionalForm initial state
Required<T>Make all properties requiredValidated form data
Pick<T, K>Select specific propertiesAPI response subset
Omit<T, K>Remove specific propertiesEntity without ID
Record<K, V>Create object typeLookup tables
ReturnType<T>Extract function return typeInfer from function

Practical example

interface User {
  id: string;
  name: string;
  email: string;
  createdAt: Date;
}
 
// For creating a new user (no id, no createdAt)
type CreateUserInput = Omit<User, 'id' | 'createdAt'>;
 
// For updating (all fields optional)
type UpdateUserInput = Partial<CreateUserInput>;
 
// For API response (pick only what UI needs)
type UserSummary = Pick<User, 'id' | 'name'>;

Discriminated unions for state

Use discriminated unions to model states that are mutually exclusive. This eliminates impossible states.

type AsyncState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };
 
function renderState<T>(state: AsyncState<T>) {
  switch (state.status) {
    case 'idle':
      return <Placeholder />;
    case 'loading':
      return <Spinner />;
    case 'success':
      return <DataView data={state.data} />;
    case 'error':
      return <ErrorMessage error={state.error} />;
  }
}

The TypeScript compiler knows exactly which properties are available in each branch. No runtime checks needed.

Branded types for type safety

Branded types prevent mixing up values that share the same primitive type.

type UserId = string & { readonly brand: unique symbol };
type PostId = string & { readonly brand: unique symbol };
 
function createUserId(id: string): UserId {
  return id as UserId;
}
 
function createPostId(id: string): PostId {
  return id as PostId;
}
 
function fetchUserPosts(userId: UserId): Promise<Post[]> {
  // Implementation
}
 
const userId = createUserId('user-123');
const postId = createPostId('post-456');
 
// This compiles
fetchUserPosts(userId);
 
// This fails! Type 'PostId' is not assignable to type 'UserId'
fetchUserPosts(postId);

Const assertions for literals

Use as const to narrow types to their literal values:

// Without as const
const routes = {
  home: '/',
  about: '/about',
  contact: '/contact',
};
// Type: { home: string, about: string, contact: string }
 
// With as const
const routes = {
  home: '/',
  about: '/about',
  contact: '/contact',
} as const;
// Type: { readonly home: "/", readonly about: "/about", readonly contact: "/contact" }
 
type Route = typeof routes[keyof typeof routes];
// Type: "/" | "/about" | "/contact"

Generic constraints for flexibility

Write generic functions with constraints to get the best of both worlds: flexibility and type safety.

interface HasId {
  id: string;
}
 
function findById<T extends HasId>(items: T[], id: string): T | undefined {
  return items.find(item => item.id === id);
}
 
// Works with any type that has an id
const users: User[] = [/* ... */];
const posts: Post[] = [/* ... */];
 
const user = findById(users, 'user-123'); // Type: User | undefined
const post = findById(posts, 'post-456'); // Type: Post | undefined

Type guards for runtime checks

Create type guards when you need runtime type checking:

interface Dog {
  type: 'dog';
  bark(): void;
}
 
interface Cat {
  type: 'cat';
  meow(): void;
}
 
type Pet = Dog | Cat;
 
function isDog(pet: Pet): pet is Dog {
  return pet.type === 'dog';
}
 
function handlePet(pet: Pet) {
  if (isDog(pet)) {
    pet.bark(); // TypeScript knows this is a Dog
  } else {
    pet.meow(); // TypeScript knows this is a Cat
  }
}

Type guards must return boolean and use the pet is Type syntax for TypeScript to narrow the type.

Template literal types

TypeScript 4.1+ supports template literal types for string manipulation at the type level:

type EventName = 'click' | 'focus' | 'blur';
type EventHandler = `on${Capitalize<EventName>}`;
// Type: "onClick" | "onFocus" | "onBlur"
 
type ApiEndpoint = `/api/${string}`;
 
function fetchFromApi(endpoint: ApiEndpoint) {
  // Implementation
}
 
fetchFromApi('/api/users'); // OK
fetchFromApi('/users'); // Error!

Error handling patterns

Use Result types instead of throwing errors for expected failures:

type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };
 
function parseJson<T>(json: string): Result<T> {
  try {
    return { ok: true, value: JSON.parse(json) };
  } catch (error) {
    return { ok: false, error: error as Error };
  }
}
 
const result = parseJson<User>('{"name": "John"}');
 
if (result.ok) {
  console.log(result.value.name); // Type-safe access
} else {
  console.error(result.error.message);
}

Conclusion

TypeScript in 2025 is about more than just adding types to JavaScript. It’s about modeling your domain accurately, eliminating impossible states, and catching bugs before they reach production.

Start with strict mode, adopt discriminated unions for state, and gradually introduce branded types and generics as your codebase grows. The upfront investment pays dividends in maintainability and confidence.