# 10 TypeScript Tips That Made My Code 50% Cleaner

# 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

1. [Use Discriminated Unions Instead of Optional Properties](#1-use-discriminated-unions-instead-of-optional-properties)
2. [Leverage Template Literal Types for String Validation](#2-leverage-template-literal-types-for-string-validation)
3. [Master the `satisfies` Operator for Better Type Inference](#3-master-the-satisfies-operator-for-better-type-inference)
4. [Create Branded Types for Primitive Values](#4-create-branded-types-for-primitive-values)
5. [Use `const` Assertions for Immutable Data Structures](#5-use-const-assertions-for-immutable-data-structures)
6. [Implement Exhaustive Type Checking with `never`](#6-implement-exhaustive-type-checking-with-never)
7. [Build Type-Safe Event Emitters with Mapped Types](#7-build-type-safe-event-emitters-with-mapped-types)
8. [Utilize Conditional Types for Function Overloads](#8-utilize-conditional-types-for-function-overloads)
9. [Create Self-Validating Types with Type Guards](#9-create-self-validating-types-with-type-guards)
10. [Use `infer` Keyword for Advanced Type Extraction](#10-use-infer-keyword-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:

```typescript
// ❌ 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

```typescript
// ✅ 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.

```typescript
// ❌ 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

```typescript
// ✅ 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:

```typescript
// ❌ 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
```

```typescript
// ❌ 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

```typescript
// ✅ 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.

```typescript
// ❌ 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

```typescript
// ✅ 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:

```typescript
// ❌ 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

```typescript
// ✅ 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.

```typescript
// ❌ 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`

```typescript
// ✅ 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:**

```typescript
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:

```typescript
// ❌ 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

```typescript
// ✅ 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:

```typescript
// ❌ 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

```typescript
// ✅ 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:

```typescript
// ❌ 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

```typescript
// ✅ 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:**

```typescript
// 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:

```typescript
// ❌ 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

```typescript
// ✅ 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:**

```typescript
// 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 `satisfies` operator** gives you type checking without losing type inference
- **Branded types** prevent mixing semantically different primitive values
- **`const` assertions** create truly immutable data structures with precise literal types
- **Exhaustive checking with `never`** ensures 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 `infer` keyword** 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
