loke.dev
Header image for 4 Patterns for High-Performance TypeScript Types (And a Faster IDE)

4 Patterns for High-Performance TypeScript Types (And a Faster IDE)

Eliminate the 'Initializing JS/TS Language Service' lag by optimizing your type definitions for the compiler's hot path.

ยท 4 min read

I once spent twenty minutes staring at a spinning "Initializing JS/TS Language Service" icon because I thought it would be "elegant" to map every possible database column to a deeply nested generic interface. My laptop fans sounded like a Boeing 747 taking off, and my productivity hit a wall hard enough to leave a mark.

We often treat the TypeScript compiler like a magical black box that can handle anything we throw at it. But as projects grow, complex types can turn your IDE into a sluggish mess. If you're tired of waiting for autocomplete to show up, here are four patterns to keep your types snappy and your compiler happy.

1. Favor Interfaces Over Intersections (&)

In the battle of interface vs. type, thereโ€™s a clear winner for performance. While they look almost identical in daily use, TypeScript handles them very differently under the hood.

When you use an intersection (type A = B & C), the compiler has to recursively merge all properties every time you use that type. It doesn't "save" the result. In contrast, interfaces create a single flat object shape that TypeScript caches.

// ๐ŸŒ Slow: The compiler re-computes the intersection constantly
type User = { id: string };
type Admin = User & { permissions: string[] };
type SuperAdmin = Admin & { override: boolean };

// ๐Ÿš€ Fast: Interfaces are cached and "flattened" by the compiler
interface User {
  id: string;
}

interface Admin extends User {
  permissions: string[];
}

interface SuperAdmin extends Admin {
  override: boolean;
}

When you extend an interface, TypeScript checks for property conflicts once and stores the result. If you're building a large design system or a complex API wrapper, stick to interface whenever possible.

2. Stop "Computing" Your Base Types

We all love utility types like Omit, Pick, and Partial. They feel dry and clever. However, if you have a massive User object with 50 fields and you Omit 10 of them in 20 different files, youโ€™re forcing the compiler to re-map that object dozens of times.

If a specific "subset" of a type is used everywhere, just declare it explicitly.

// ๐ŸŒ Heavy lifting for the compiler
type UserPreview = Omit<User, 'password' | 'bio' | 'createdAt' | 'updatedAt'>;

// ๐Ÿš€ Better: Just define the specific shape
interface UserPreview {
  id: string;
  name: string;
  email: string;
}

Think of it as "type memoization." By defining the shape explicitly, youโ€™re giving the compiler a break. It no longer has to subtract keys from a giant object to figure out what UserPreview looks like.

3. The Power of "Discriminated Unions"

Narrowing types using if (obj.type === 'check') isn't just a good runtime practice; itโ€™s a massive performance win for the type checker.

When you use complex conditional logic or "look-ahead" checks (like checking if a property exists using in), the compiler has to do a lot of heavy lifting to prove the type is safe. A discriminated union uses a literal string or number to create a clear shortcut.

// ๐ŸŒ The "Wait, what is this?" approach
type UploadResult = {
  data?: string;
  error?: string;
  loading: boolean;
};

// ๐Ÿš€ The "Discriminated Union" approach
type UploadResult = 
  | { status: 'loading' }
  | { status: 'success'; data: string }
  | { status: 'error'; error: string };

function handleUpload(result: UploadResult) {
  // TypeScript knows exactly which branch to take instantly
  if (result.status === 'success') {
    console.log(result.data);
  }
}

By adding that status field, youโ€™ve turned a complex logical puzzle into a simple lookup for the compiler. This prevents the "type explosion" that happens when the compiler tries to cross-reference every possible optional property.

4. Beware the Template Literal Type Explosion

TypeScript 4.1 introduced template literal types, and they are incredibly cool. You can do things like type Color = bg-${'red' | 'blue'}-${100 | 200}. But with great power comes the ability to accidentally crash your IDE.

Every time you combine unions in a template literal, the number of types grows exponentially (multiplicatively).

type Hue = 'red' | 'blue' | 'green' | 'yellow' | 'purple'; // 5
type Intensity = 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900; // 9
type Opacity = 0 | 10 | 20 | 30 | 40 | 50 | 60 | 70 | 80 | 90 | 100; // 11

// This generates 5 * 9 * 11 = 495 unique string types
type ColorClass = `text-${Hue}-${Intensity}/${Opacity}`;

If you start nesting these or using them in large arrays, the compiler has to track every single permutation. Itโ€™s fun for a side project, but in a production codebase with hundreds of components, it can lead to the dreaded "Type instantiation is excessively deep" error.

If you find yourself generating thousands of string literals, it might be time to use a simple string type and rely on a small runtime validator instead.

How to check if your types are the problem

If your IDE is still crawling, you can actually see what's happening. Run this in your terminal:

tsc --performance --noEmit

Look for the "Check time." If it's significantly higher than your "I/O time" or "Parse time," you probably have some "hot" types that need flattening. You can also use tsc --generateTrace traceDir to get a JSON file that you can inspect in Chrome DevTools (chrome://tracing) to see exactly which files and types are sucking up the most milliseconds.

Writing performant TypeScript isn't about being less type-safe; it's about being more intentional. Your CPU (and your sanity) will thank you.