Skip to main content
Ganesh Joshi
Back to Blogs

TypeScript tips for cleaner and safer code

February 9, 20265 min read
Tutorials
Programming code on screen, IDE

TypeScript catches bugs before runtime by adding static types to JavaScript. But effective TypeScript isn't about typing everything—it's about typing strategically. These tips help you write cleaner, safer code without drowning in type annotations.

Let TypeScript infer

TypeScript infers types automatically. You don't need to annotate everything:

// Unnecessary annotation
const count: number = 0;
const name: string = 'Alice';
const items: string[] = ['a', 'b', 'c'];

// Let TypeScript infer
const count = 0;            // number
const name = 'Alice';       // string
const items = ['a', 'b', 'c']; // string[]

Add explicit types for:

  • Function parameters
  • Public API boundaries
  • When inference isn't specific enough

Interface vs type

Use case Prefer
Object shapes interface
Unions type
Intersections type
Extending interface
Mapped types type
// Interface for objects
interface User {
  id: string;
  name: string;
  email: string;
}

// Type for unions and complex types
type Status = 'idle' | 'loading' | 'success' | 'error';
type Result<T> = { data: T } | { error: string };

Both work for most cases. Pick a convention and stick with it.

Union types

Model "one of several" with unions:

type Status = 'pending' | 'approved' | 'rejected';

function handleStatus(status: Status) {
  switch (status) {
    case 'pending':
      return 'Waiting...';
    case 'approved':
      return 'Accepted!';
    case 'rejected':
      return 'Denied';
  }
}

TypeScript enforces exhaustive checks—add a new status and the compiler warns.

Discriminated unions

Add a common property to narrow types:

type ApiResponse =
  | { status: 'success'; data: User[] }
  | { status: 'error'; message: string }
  | { status: 'loading' };

function render(response: ApiResponse) {
  switch (response.status) {
    case 'success':
      // TypeScript knows response.data exists
      return response.data.map(user => user.name);
    case 'error':
      // TypeScript knows response.message exists
      return `Error: ${response.message}`;
    case 'loading':
      return 'Loading...';
  }
}

The status field discriminates between variants.

Type narrowing

TypeScript narrows types based on control flow:

function process(value: string | number | null) {
  if (value === null) {
    return 'No value';
  }

  // value: string | number

  if (typeof value === 'string') {
    return value.toUpperCase(); // value: string
  }

  return value.toFixed(2); // value: number
}

Custom type guards

For complex narrowing:

interface Dog { bark(): void }
interface Cat { meow(): void }

function isDog(pet: Dog | Cat): pet is Dog {
  return 'bark' in pet;
}

function speak(pet: Dog | Cat) {
  if (isDog(pet)) {
    pet.bark(); // TypeScript knows it's Dog
  } else {
    pet.meow(); // TypeScript knows it's Cat
  }
}

Avoid any

any disables type checking. Use alternatives:

Instead of Use
any for unknown data unknown
any for flexible functions Generics
any for third-party Proper types or @ts-expect-error
// Bad: any
function parse(data: any) {
  return data.items; // No type checking
}

// Better: unknown with narrowing
function parse(data: unknown) {
  if (typeof data === 'object' && data !== null && 'items' in data) {
    return data.items; // Type narrowed
  }
  throw new Error('Invalid data');
}

// Best: generic with constraints
function parse<T extends { items: unknown[] }>(data: T) {
  return data.items; // Typed correctly
}

Utility types

TypeScript provides built-in type utilities:

Utility Purpose
Partial<T> All properties optional
Required<T> All properties required
Pick<T, K> Select specific properties
Omit<T, K> Exclude specific properties
Record<K, V> Object type with key/value
Readonly<T> All properties readonly
interface User {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'user';
}

// For updates, make everything optional
type UpdateUser = Partial<User>;

// For public display, omit sensitive fields
type PublicUser = Omit<User, 'email'>;

// For lookup maps
type UserMap = Record<string, User>;

Const assertions

Preserve literal types:

// Without as const
const config = {
  api: 'https://api.example.com',
  timeout: 5000,
};
// config.api: string

// With as const
const config = {
  api: 'https://api.example.com',
  timeout: 5000,
} as const;
// config.api: 'https://api.example.com'

Useful for configuration and constant values.

Nullish handling

Use optional chaining and nullish coalescing:

interface User {
  profile?: {
    avatar?: string;
  };
}

// Old way
const avatar = user && user.profile && user.profile.avatar
  ? user.profile.avatar
  : 'default.png';

// Modern way
const avatar = user?.profile?.avatar ?? 'default.png';

Generic functions

Write flexible, typed functions:

// Without generics: loses type info
function first(arr: unknown[]): unknown {
  return arr[0];
}

// With generics: preserves type
function first<T>(arr: T[]): T | undefined {
  return arr[0];
}

const num = first([1, 2, 3]);    // number | undefined
const str = first(['a', 'b']);   // string | undefined

Generic constraints

Constrain what generics accept:

interface HasId {
  id: string;
}

function findById<T extends HasId>(items: T[], id: string): T | undefined {
  return items.find(item => item.id === id);
}

Strict mode

Enable strict in tsconfig.json:

{
  "compilerOptions": {
    "strict": true
  }
}

This enables:

  • strictNullChecks - null/undefined safety
  • noImplicitAny - no inferred any
  • strictFunctionTypes - function type safety
  • strictPropertyInitialization - class property init

Type assertions (sparingly)

Use as when you know more than TypeScript:

// After manual validation
const data = JSON.parse(response) as Config;

// After DOM query
const input = document.getElementById('email') as HTMLInputElement;

Prefer type guards when possible—they're runtime checked.

Branded types

Create distinct types from primitives:

type UserId = string & { readonly brand: unique symbol };
type OrderId = string & { readonly brand: unique symbol };

function getUser(id: UserId) { ... }
function getOrder(id: OrderId) { ... }

const userId = '123' as UserId;
const orderId = '456' as OrderId;

getUser(userId);  // OK
getUser(orderId); // Error! OrderId is not UserId

Prevents mixing up IDs of the same underlying type.

Summary

Let TypeScript infer when obvious. Use interfaces for objects, types for unions. Model states with discriminated unions. Narrow types with control flow and type guards. Avoid any—use unknown and generics instead. Enable strict mode for maximum safety. These patterns catch bugs early while keeping code readable.

Frequently Asked Questions

Use interface for object shapes that might be extended or implemented. Use type for unions, intersections, and mapped types. Both work for most cases; pick one convention per project.

Use unknown for values with unknown types, then narrow with type guards. Use generics for flexible but typed functions. Enable strict mode to catch any usage.

Type narrowing is when TypeScript infers a more specific type based on control flow. After checking if (typeof x === 'string'), TypeScript knows x is a string in that block.

No. Let TypeScript infer types for local variables and function returns when obvious. Add explicit types for function parameters, public APIs, and where inference falls short.

Discriminated unions are union types where each member has a common property with a literal type. TypeScript uses this property to narrow the type in conditionals.

Related Posts