10 TypeScript Tips That Made My Code 50% Cleaner
Learn: 10 TypeScript Tips That Made My Code 50% Cleaner
Welcome to TopperBlog! π
I'm a tech content creator passionate about helping developers level up their careers and master cutting-edge technologies.
π― What I Write About:
β’ AI/ML Engineering & LLMs
β’ Web3 & Blockchain Development
β’ System Design & Architecture
β’ Interview Preparation (FAANG)
β’ Freelancing & Remote Work
β’ Modern Tech Stacks (Next.js, React, Rust, TypeScript)
β’ Performance Optimization & Best Practices
πΌ Mission: Sharing practical, actionable insights that accelerate your tech career and maximize your earning potential.
π 15+ In-Depth Guides covering everything from earning $10k/month as a freelancer to cracking FAANG interviews.
π Let's connect and grow together in this amazing tech journey!
#TechBlogger #SoftwareEngineering #CareerGrowth #WebDevelopment #AIEngineering
10 TypeScript Tips That Made My Code 50% Cleaner
I Used to Hate My Own Code. Then I Learned These TypeScript Tricks.
Six months ago, I was debugging a production issue at 2 AM. Again. The problem? A simple typo in a property name that JavaScript happily ignored until it crashed our checkout flow. That night, I decided enough was enough and dove deep into TypeScript's type system.
What I discovered changed everything. My code became self-documenting, bugs decreased by half, and code reviews went from painful to productive. I'm sharing the 10 TypeScript tips that transformed my development workflowβno fluff, just practical techniques I use daily.
Table of Contents
- Use Discriminated Unions Instead of Optional Properties
- Leverage Template Literal Types for String Validation
- Master the
satisfiesOperator for Better Type Inference - Create Branded Types for Primitive Values
- Use
constAssertions for Immutable Data Structures - Implement Exhaustive Type Checking with
never - Build Type-Safe Event Emitters with Mapped Types
- Utilize Conditional Types for Function Overloads
- Create Self-Validating Types with Type Guards
- Use
inferKeyword for Advanced Type Extraction
1. Use Discriminated Unions Instead of Optional Properties
The Problem I Faced
I used to write types like this, thinking I was being thorough:
// β Before: Confusing optional properties
interface ApiResponse {
status: 'success' | 'error';
data?: User;
error?: string;
}
The issue? TypeScript couldn't help me ensure that data exists when status is 'success', or that error exists when status is 'error'. I constantly wrote defensive code checking both properties.
The Solution: Discriminated Unions
// β
After: Crystal clear discriminated unions
type ApiResponse =
| { status: 'success'; data: User }
| { status: 'error'; error: string };
function handleResponse(response: ApiResponse) {
if (response.status === 'success') {
// TypeScript knows response.data exists here
console.log(response.data.name);
} else {
// TypeScript knows response.error exists here
console.log(response.error);
}
}
Why this works: The discriminant property (status) tells TypeScript exactly which shape the object has. No more guessing, no more unnecessary null checks.
2. Leverage Template Literal Types for String Validation
From Runtime Errors to Compile-Time Safety
I was building a CSS-in-JS library and kept making typos in CSS property names. Runtime errors were frequent.
// β Before: Any string accepted
function setStyle(property: string, value: string) {
element.style[property] = value; // Typos slip through
}
setStyle('backgrond-color', 'red'); // Oops! No error
Template Literal Types to the Rescue
// β
After: Type-safe CSS properties
type CSSProperty =
| 'background-color'
| 'font-size'
| 'margin'
| 'padding';
type CSSPropertyWithPrefix<T extends string> =
`${T}` | `-webkit-${T}` | `-moz-${T}`;
type SafeCSSProperty = CSSPropertyWithPrefix<CSSProperty>;
function setStyle(property: SafeCSSProperty, value: string) {
element.style[property] = value;
}
setStyle('background-color', 'red'); // β
Works
setStyle('-webkit-background-color', 'red'); // β
Works
setStyle('backgrond-color', 'red'); // β Compile error!
Real-world impact: I caught 23 typos in my codebase the day I implemented this pattern.
3. Master the satisfies Operator for Better Type Inference
The Type Annotation Dilemma
Before TypeScript 4.9, I faced a frustrating choice:
// β Option 1: Lose specific type information
const config: Record<string, string | number> = {
apiUrl: 'https://api.example.com',
timeout: 5000,
retries: 3
};
config.apiUrl.toUpperCase(); // β Error: Property 'toUpperCase' doesn't exist
// β Option 2: No type checking
const config = {
apiUrl: 'https://api.example.com',
timeout: 5000,
retries: 3
};
config.apiUrl = 123; // β
No error, but wrong!
The satisfies Operator Solution
// β
Best of both worlds
type Config = Record<string, string | number>;
const config = {
apiUrl: 'https://api.example.com',
timeout: 5000,
retries: 3
} satisfies Config;
config.apiUrl.toUpperCase(); // β
Works! TypeScript knows it's a string
config.timeout.toFixed(2); // β
Works! TypeScript knows it's a number
config.apiUrl = 123; // β Error caught!
Why I love this: You get validation without sacrificing type inference. It's like having your cake and eating it too.
4. Create Branded Types for Primitive Values
When Strings Aren't Just Strings
I once passed a user ID where an order ID was expected. Both were strings, so TypeScript didn't complain. The bug took hours to find.
// β Before: All strings are equal
type UserId = string;
type OrderId = string;
function getOrder(orderId: OrderId) { /* ... */ }
const userId: UserId = 'user_123';
getOrder(userId); // β
No error, but logically wrong!
Branded Types for Type Safety
// β
After: Branded types prevent mixing
type Brand<K, T> = K & { __brand: T };
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
function createUserId(id: string): UserId {
return id as UserId;
}
function createOrderId(id: string): OrderId {
return id as OrderId;
}
function getOrder(orderId: OrderId) { /* ... */ }
const userId = createUserId('user_123');
const orderId = createOrderId('order_456');
getOrder(orderId); // β
Works
getOrder(userId); // β Compile error!
Comparison Table:
| Approach | Type Safety | Runtime Cost | Refactoring Ease |
| Plain strings | β Low | None | β Difficult |
| Branded types | β High | None | β Easy |
| Classes | β High | Memory overhead | β οΈ Moderate |
5. Use const Assertions for Immutable Data Structures
Mutable by Default Was Killing Me
I had a configuration object that I never intended to modify, but TypeScript treated it as mutable:
// β Before: Mutable and imprecise
const routes = {
home: '/',
about: '/about',
contact: '/contact'
};
// TypeScript infers: { home: string; about: string; contact: string }
routes.home = '/new-home'; // β
Allowed, but I don't want this!
const Assertions Lock It Down
// β
After: Immutable and precise
const routes = {
home: '/',
about: '/about',
contact: '/contact'
} as const;
// TypeScript infers: { readonly home: "/"; readonly about: "/about"; ... }
routes.home = '/new-home'; // β Error: Cannot assign to 'home'
// Bonus: Literal types instead of string
type Route = typeof routes[keyof typeof routes]; // "/" | "/about" | "/contact"
Use cases I've found:
- Configuration objects
- Enum-like constants
- Lookup tables
- Action type constants in Redux
6. Implement Exhaustive Type Checking with never
Catching Missing Cases at Compile Time
I was maintaining a state machine with multiple states. When a new state was added, I forgot to handle it in several switch statements. Production bugs ensued.
// β Before: Silent failures
type State = 'idle' | 'loading' | 'success' | 'error';
function handleState(state: State) {
switch (state) {
case 'idle':
return 'Waiting...';
case 'loading':
return 'Loading...';
case 'success':
return 'Done!';
// Forgot 'error' case - no compile error!
}
}
Exhaustive Checking with never
// β
After: Compile-time exhaustiveness
type State = 'idle' | 'loading' | 'success' | 'error';
function assertNever(value: never): never {
throw new Error(`Unhandled value: ${value}`);
}
function handleState(state: State) {
switch (state) {
case 'idle':
return 'Waiting...';
case 'loading':
return 'Loading...';
case 'success':
return 'Done!';
default:
return assertNever(state); // β Error: 'error' not handled!
}
}
When I add a new state:
type State = 'idle' | 'loading' | 'success' | 'error' | 'retrying';
// TypeScript immediately shows errors in ALL places where I need to handle 'retrying'
7. Build Type-Safe Event Emitters with Mapped Types
Event Emitters Were a Type Safety Black Hole
Traditional event emitters accept any string and any payload:
// β Before: No type safety
emitter.on('user:login', (data) => {
console.log(data.userId); // What's in data? Who knows!
});
emitter.emit('user:login', { userId: 123 });
emitter.emit('user:login', 'wrong data'); // β
No error!
emitter.emit('user:logn', { userId: 123 }); // β
Typo not caught!
Type-Safe Event Emitter
// β
After: Fully typed events
interface EventMap {
'user:login': { userId: number; timestamp: Date };
'user:logout': { userId: number };
'order:created': { orderId: string; amount: number };
}
class TypedEventEmitter<T extends Record<string, any>> {
on<K extends keyof T>(event: K, handler: (data: T[K]) => void) {
// Implementation
}
emit<K extends keyof T>(event: K, data: T[K]) {
// Implementation
}
}
const emitter = new TypedEventEmitter<EventMap>();
emitter.on('user:login', (data) => {
console.log(data.userId); // β
TypeScript knows the shape!
console.log(data.timestamp.toISOString()); // β
Full autocomplete
});
emitter.emit('user:login', { userId: 123, timestamp: new Date() }); // β
Works
emitter.emit('user:login', { userId: 123 }); // β Error: missing timestamp
emitter.emit('user:logn', { userId: 123 }); // β Error: typo caught!
8. Utilize Conditional Types for Function Overloads
Function Overloads Were Getting Messy
I had a function that could accept different input types and return different output types:
// β Before: Verbose overloads
function process(input: string): string;
function process(input: number): number;
function process(input: boolean): string;
function process(input: string | number | boolean): string | number {
// Implementation with lots of type guards
}
Conditional Types Simplify Everything
// β
After: Clean conditional types
type ProcessOutput<T> =
T extends string ? string :
T extends number ? number :
T extends boolean ? string :
never;
function process<T extends string | number | boolean>(input: T): ProcessOutput<T> {
if (typeof input === 'string') {
return input.toUpperCase() as ProcessOutput<T>;
}
if (typeof input === 'number') {
return (input * 2) as ProcessOutput<T>;
}
return String(input) as ProcessOutput<T>;
}
const result1 = process('hello'); // Type: string
const result2 = process(42); // Type: number
const result3 = process(true); // Type: string
Benefits I've experienced:
- Less code duplication
- Better type inference
- Easier to maintain and extend
9. Create Self-Validating Types with Type Guards
Runtime Validation That Teaches TypeScript
I was constantly writing validation code that TypeScript couldn't understand:
// β Before: Validation doesn't narrow types
interface User {
id: number;
email: string;
age: number;
}
function isValidUser(obj: any): boolean {
return typeof obj.id === 'number' &&
typeof obj.email === 'string' &&
typeof obj.age === 'number';
}
const data: any = fetchData();
if (isValidUser(data)) {
console.log(data.email); // β Still 'any' type!
}
Type Guards Bridge Runtime and Compile Time
// β
After: Type guards narrow types
interface User {
id: number;
email: string;
age: number;
}
function isUser(obj: any): obj is User {
return typeof obj === 'object' &&
obj !== null &&
typeof obj.id === 'number' &&
typeof obj.email === 'string' &&
typeof obj.age === 'number';
}
const data: any = fetchData();
if (isUser(data)) {
console.log(data.email); // β
TypeScript knows it's User!
console.log(data.email.toLowerCase()); // β
Full autocomplete
}
Advanced pattern I use:
// Generic type guard factory
function createTypeGuard<T>(
validator: (obj: any) => boolean
): (obj: any) => obj is T {
return (obj: any): obj is T => validator(obj);
}
const isUser = createTypeGuard<User>(
(obj) => typeof obj?.id === 'number' &&
typeof obj?.email === 'string'
);
10. Use infer Keyword for Advanced Type Extraction
Extracting Types from Complex Structures
I needed to extract return types from promise-returning functions, and the manual approach was tedious:
// β Before: Manual type extraction
async function fetchUser() {
return { id: 1, name: 'John' };
}
type User = { id: number; name: string }; // Had to duplicate!
infer Automates Type Extraction
// β
After: Automatic type extraction
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
async function fetchUser() {
return { id: 1, name: 'John' };
}
type User = UnwrapPromise<ReturnType<typeof fetchUser>>;
// Automatically: { id: number; name: string }
More powerful patterns:
// Extract array element type
type ArrayElement<T> = T extends (infer U)[] ? U : never;
type Numbers = ArrayElement<number[]>; // number
// Extract function parameters
type Parameters<T> = T extends (...args: infer P) => any ? P : never;
// Extract nested property types
type DeepValue<T, K extends string> =
K extends `${infer First}.${infer Rest}`
? First extends keyof T
? DeepValue<T[First], Rest>
: never
: K extends keyof T
? T[K]
: never;
interface Config {
database: {
host: string;
port: number;
};
}
type Host = DeepValue<Config, 'database.host'>; // string
Frequently Asked Questions
How much does TypeScript slow down development?
In my experience, TypeScript adds about 10% more time upfront but saves 50%+ time in debugging and maintenance. After the initial learning curve, I'm actually faster because autocomplete and type checking catch issues immediately.
Should I use strict mode in TypeScript?
Absolutely. I enable strict: true in every project. It caught hundreds of potential bugs in my legacy codebase. Start with strict mode on new projects, and gradually enable it for existing ones.
When should I use any type?
Rarely. I only use any when integrating with untyped third-party libraries or during rapid prototyping. Even then, I add a // TODO: type this properly comment and come back to it. Use unknown instead when you truly don't know the type.
How do I convince my team to adopt TypeScript?
Start small. I converted one critical module to TypeScript and demonstrated how it caught 3 production bugs during the conversion. Show, don't tell. The productivity gains speak for themselves.
What's the best way to learn advanced TypeScript?
Build real projects and read error messages carefully. TypeScript's error messages are incredibly informative. I also recommend reading the TypeScript release notesβeach version introduces powerful features that solve real problems.
Key Takeaways
- Discriminated unions eliminate impossible states and make your code self-documenting
- Template literal types catch string typos at compile time instead of runtime
- The
satisfiesoperator gives you type checking without losing type inference - Branded types prevent mixing semantically different primitive values
constassertions create truly immutable data structures with precise literal types- Exhaustive checking with
neverensures you handle all cases in unions and switches - Type-safe event emitters bring type safety to traditionally unsafe patterns
- Conditional types replace verbose function overloads with elegant type logic
- Type guards bridge the gap between runtime validation and compile-time types
- The
inferkeyword automates complex type extraction patterns
Conclusion: Type Safety Is a Superpower
Six months ago, I was skeptical about TypeScript's complexity. Today, I can't imagine writing JavaScript without it. These 10 tips transformed my code from a minefield of potential bugs into a self-documenting, maintainable codebase.
The best part? I'm still discovering new patterns. TypeScript's type system is deep enough to keep you learning, yet practical enough to use daily. Start with