# 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:
- No implicit
any: Forces you to type things properly instead of letting them slip through. - Strict null checks:
nullandundefinedare no longer assignable to everything. This caught a nasty bug where we assumed a Redis response would always have adatafield—it didn't, and the app crashed in prod. - Strict function types: Ensures callbacks are typed correctly. Prevented a bug where we passed a
(err: Error, data: any) => voidcallback to a function expecting(data: string) => void.
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:
- Exhaustiveness checking: If you add a new status (e.g., "refunded"), TS will yell at you until you handle it in the switch.
- No invalid states: Can't accidentally have a payment with
status: "completed"but nocompletedAt. - Better than enums: Enums are just numbers under the hood. Discriminated unions are type-safe and self-documenting.
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:
- Less duplication. If
Userchanges,PublicUserupdates automatically. - Self-documenting.
Partial<User>clearly communicates intent.
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:
- No more
anyin Redis responses. The type flows through the entire cache layer. - Works with any type—no need to write separate
getUser,getOrder, etc. methods. - Still type-safe. If you try to access
user.foo(wherefooisn't inUserCache), TS will error.
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:
- No type assertions (
as User). The type narrowing happens automatically. - Works with complex types (e.g., discriminated unions).
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:
- Migrating JS to TS incrementally. You can't fix everything at once.
- Third-party libraries without types. But consider writing a minimal type declaration instead.
### 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:
- No more
req.user as any. - Middleware chain is type-safe. If
authenticatedoesn't setreq.user, TS will error. - Self-documenting. The
UserRequestinterface makes it clear what's available in the request.
---
## 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