Back to blog

10 TypeScript Tips That Changed How I Write Code

November 20, 20244 min read
TypeScriptJavaScriptTips

TypeScript has fundamentally changed how I approach software development. Here are 10 tips that made the biggest difference in my code quality.

1. Use const Assertions for Literal Types

Instead of getting broad types, lock them down:

// Without const assertion
const config = {
  env: "production",
  port: 3000,
}; // type: { env: string, port: number }

// With const assertion
const config = {
  env: "production",
  port: 3000,
} as const; // type: { readonly env: "production", readonly port: 3000 }

2. Discriminated Unions for State Management

Perfect for handling different states in your application:

type RequestState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };

function handleState<T>(state: RequestState<T>) {
  switch (state.status) {
    case "idle":
      return "Ready to fetch";
    case "loading":
      return "Loading...";
    case "success":
      return `Got ${state.data}`; // TypeScript knows data exists
    case "error":
      return `Error: ${state.error.message}`; // TypeScript knows error exists
  }
}

Discriminated unions eliminate entire categories of bugs by making impossible states unrepresentable.

3. Template Literal Types

Create precise string types:

type HTTPMethod = "GET" | "POST" | "PUT" | "DELETE";
type APIEndpoint = `/api/${string}`;
type APIRoute = `${HTTPMethod} ${APIEndpoint}`;

const route: APIRoute = "GET /api/users"; // ✅
const badRoute: APIRoute = "FETCH /api/users"; // ❌ Error!

4. The satisfies Operator

Validate types without widening:

type Colors = Record<string, [number, number, number] | string>;

const palette = {
  red: [255, 0, 0],
  green: "#00ff00",
  blue: [0, 0, 255],
} satisfies Colors;

// palette.red is still [number, number, number], not string | [number, number, number]
const redChannel = palette.red[0]; // ✅ Works!

5. Utility Types Are Your Friends

Master these built-in helpers:

interface User {
  id: string;
  name: string;
  email: string;
  password: string;
}

// Pick only what you need
type PublicUser = Pick<User, "id" | "name">;

// Omit sensitive fields
type SafeUser = Omit<User, "password">;

// Make everything optional for updates
type UserUpdate = Partial<User>;

// Make everything required
type RequiredUser = Required<User>;

// Read-only version
type FrozenUser = Readonly<User>;

6. Generic Constraints

Don't just use any - constrain your generics:

// Bad
function getProperty<T>(obj: T, key: string): any {
  return obj[key];
}

// Good
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { name: "Dapo", age: 25 };
const name = getProperty(user, "name"); // type: string
const age = getProperty(user, "age"); // type: number

7. Infer Types from Functions

Let TypeScript do the work:

function createUser(name: string, age: number) {
  return { id: crypto.randomUUID(), name, age, createdAt: new Date() };
}

// Automatically infer the return type
type User = ReturnType<typeof createUser>;
// { id: string, name: string, age: number, createdAt: Date }

// Get parameter types
type CreateUserParams = Parameters<typeof createUser>;
// [string, number]

8. Branded Types for Type Safety

Prevent mixing up similar types:

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 getUser(id: UserId) {
  /* ... */
}

const userId = createUserId("user-123");
const postId = createPostId("post-456");

getUser(userId); // ✅
getUser(postId); // ❌ Error: PostId is not assignable to UserId

9. Exhaustive Checks with never

Catch missing cases at compile time:

type Shape = "circle" | "square" | "triangle";

function getArea(shape: Shape): number {
  switch (shape) {
    case "circle":
      return Math.PI * 10 * 10;
    case "square":
      return 10 * 10;
    case "triangle":
      return (10 * 10) / 2;
    default:
      // This will error if we add a new shape but forget to handle it
      const _exhaustive: never = shape;
      throw new Error(`Unknown shape: ${_exhaustive}`);
  }
}

10. Type Guards for Runtime Safety

Bridge the gap between compile-time and runtime:

interface Dog {
  bark(): void;
  breed: string;
}

interface Cat {
  meow(): void;
  color: string;
}

// Type guard function
function isDog(pet: Dog | Cat): pet is Dog {
  return "bark" in pet;
}

function handlePet(pet: Dog | Cat) {
  if (isDog(pet)) {
    pet.bark(); // TypeScript knows it's a Dog
    console.log(pet.breed);
  } else {
    pet.meow(); // TypeScript knows it's a Cat
    console.log(pet.color);
  }
}

Bonus: A TypeScript Haiku

Types flow like water, Catching bugs before they swim, Peace in strict mode's arms.


Want to dive deeper? Check out the TypeScript Handbook and Total TypeScript for advanced patterns.

What's your favorite TypeScript tip? Let me know on Twitter!