Back to Blog

TypeScript Tips That Genuinely Improved My Code Quality

# TypeScript Tips That Genuinely Improved My Code Quality

Been writing TypeScript for about 5 years now—started when we migrated a 50k-line Node.js backend from JS to TS. Not gonna lie, the first few months were rough. The compiler felt like that overzealous senior dev who nitpicks every semicolon. But once we got past the initial pain, the payoff was real. Fewer runtime errors, easier refactoring, and way less "WTF is this object shape?" moments in code reviews.

Here are the TypeScript practices that actually moved the needle for us in production—not just theoretical "nice-to-haves" from blog posts.

---

## 1. Strict Mode: Just Turn It On Already

If you're not using strict: true in your tsconfig.json, you're leaving money on the table. It's like driving without seatbelts—works fine until it doesn't.

{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true
  }
}

The big wins:

Tradeoff: Initial migration hurts. We had to fix ~300 errors in our first pass. But it's a one-time pain. New code is written strictly from day one, and the compiler catches stupid mistakes before they hit CI.

---

## 2. Discriminated Unions: The Swiss Army Knife for State Modeling

This is the single TypeScript feature that reduced our state-related bugs by ~60%. Discriminated unions let you model "either/or" scenarios cleanly.

Example: Payment processing states. In JS, you'd do this:

// Old JS way - error-prone
const payment = {
  id: "123",
  status: "processing", // what if this is "processsing"?
  amount: 100
};

In TypeScript with discriminated unions:

type Payment =
  | { id: string; status: "pending"; amount: number }
  | { id: string; status: "processing"; amount: number; startedAt: Date }
  | { id: string; status: "completed"; amount: number; completedAt: Date }
  | { id: string; status: "failed"; amount: number; error: string };

function handlePayment(payment: Payment) {
  switch (payment.status) {
    case "pending":
      // TS knows payment has no startedAt/completedAt/error here
      queuePayment(payment);
      break;
    case "processing":
      // TS knows payment has startedAt but not completedAt/error
      monitorPayment(payment);
      break;
    // ... other cases
  }
}

Why this matters:

Production use case: We used this for modeling Kafka message types. Each message had a type field (e.g., "user_created", "order_shipped") and the payload was strictly typed based on the type. Eliminated a class of bugs where consumers assumed wrong payload shapes.

---

## 3. Utility Types: Don't Reinvent the Wheel

TypeScript's built-in utility types (Partial, Pick, Omit, etc.) save you from writing brittle custom types. Here's how we use them:

### Pick and Omit for API Contracts

interface User {
  id: string;
  name: string;
  email: string;
  passwordHash: string;
  createdAt: Date;
}

// Only expose safe fields to clients
type PublicUser = Omit<User, "passwordHash">;

// Or explicitly pick what you need
type UserProfile = Pick<User, "id" | "name" | "email">;

### Partial for PATCH Requests

async function updateUser(id: string, update: Partial<User>) {
  // Update only the provided fields
}

Why this beats manual types:

Tradeoff: Overusing utility types can make types harder to read. If you find yourself doing Omit<Pick<Partial<User>, "name">, "id">, you've gone too far. Keep it simple.

---

## 4. Generics: When You Need Flexibility Without Losing Safety

Generics let you write reusable, type-safe functions without resorting to any. Here's a practical example:

### Type-Safe Redis Wrapper

import { Redis } from "ioredis";

class RedisClient {
  private client: Redis;

  constructor() {
    this.client = new Redis();
  }

  async get<T>(key: string): Promise<T | null> {
    const data = await this.client.get(key);
    return data ? JSON.parse(data) : null;
  }

  async set<T>(key: string, value: T, ttl?: number): Promise<void> {
    const stringValue = JSON.stringify(value);
    if (ttl) {
      await this.client.setex(key, ttl, stringValue);
    } else {
      await this.client.set(key, stringValue);
    }
  }
}

// Usage
interface UserCache {
  id: string;
  name: string;
  email: string;
}

const redis = new RedisClient();
await redis.set<UserCache>("user:123", { id: "123", name: "Alice", email: "[email protected]" });
const user = await redis.get<UserCache>("user:123"); // TS knows user is UserCache | null

Why this matters:

Tradeoff: Generics add complexity. Don't use them just because they're cool. If a function only works with User objects, just type it as User—don't make it generic.

---

## 5. Type Guards: Runtime Safety Without Losing Type Info

TypeScript types disappear at runtime, but type guards let you keep type safety while doing runtime checks.

### Example: Validating API Responses

interface SuccessResponse<T> {
  success: true;
  data: T;
}

interface ErrorResponse {
  success: false;
  error: string;
}

type ApiResponse<T> = SuccessResponse<T> | ErrorResponse;

function isSuccessResponse<T>(response: ApiResponse<T>): response is SuccessResponse<T> {
  return response.success;
}

// Usage
async function fetchUser(id: string): Promise<User> {
  const res = await axios.get<ApiResponse<User>>(`/api/users/${id}`);

  if (!isSuccessResponse(res.data)) {
    throw new Error(res.data.error);
  }

  // TS now knows res.data.data is User
  return res.data.data;
}

Why this beats any:

Common pitfall: Writing type guards that don't actually check enough. For example:

// Bad - just checks if data exists, not its shape
function isUser(data: any): data is User {
  return !!data;
}

// Good - checks the actual shape
function isUser(data: any): data is User {
  return (
    typeof data === "object" &&
    typeof data.id === "string" &&
    typeof data.name === "string"
  );
}

---

## 6. Avoiding `any`: The Slippery Slope

any is TypeScript's escape hatch. Use it sparingly, or you'll end up with "TypeScript in name only."

### When `any` is (sometimes) okay:

### Better alternatives to `any`:

1. unknown: Forces you to check the type before using it.

   function parseJson(json: string): unknown {
     return JSON.parse(json);
   }

   const data = parseJson('{"foo": "bar"}');
   if (typeof data === "object" && data && "foo" in data) {
     console.log(data.foo); // TS knows data.foo is string
   }

2. Type assertions: Use as when you're *absolutely sure* of the type.

   const input = document.getElementById("user-input") as HTMLInputElement;
   // Now TS knows input.value exists

3. Generics: As shown earlier, generics let you keep type safety while working with unknown types.

Production horror story: We had a config object typed as any because it came from a JSON file. A typo (config.timeout instead of config.timeOut) slipped through, and the app silently used undefined as the timeout value. With unknown, TS would have forced us to check the shape first.

---

## 7. Node.js Example: Type-Safe Express Middleware

Here's how we type our Express middleware to avoid "any" hell:

import { Request, Response, NextFunction } from "express";

interface UserRequest extends Request {
  user?: {
    id: string;
    role: "admin" | "user";
  };
}

function authenticate(req: UserRequest, res: Response, next: NextFunction) {
  const token = req.headers.authorization?.split(" ")[1];
  if (!token) {
    return res.status(401).json({ error: "Unauthorized" });
  }

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET!) as { id: string; role: "admin" | "user" };
    req.user = payload; // TS knows req.user is the right shape
    next();
  } catch (err) {
    res.status(401).json({ error: "Invalid token" });
  }
}

// Usage
app.get("/profile", authenticate, (req: UserRequest, res) => {
  if (!req.user) {
    return res.status(401).json({ error: "Unauthorized" });
  }
  // TS knows req.user.id and req.user.role exist
});

Key improvements over untyped Express:

---

## 8. System Design Example: Type-Safe Event Bus

Here's how we typed our internal event bus to prevent "message shape" bugs:

type EventMap = {
  "user.created": { id: string; email: string };
  "order.shipped