DEV Community

Timevolt
Timevolt

Posted on

TypeScript Tips to Write Safer, Cleaner Code: Dodging Bullets Like Neo

The Quest Begins (The "Why")

I still remember the day I shipped a feature that seemed perfect—until a user clicked a button and the app blew up with Cannot read property 'length' of undefined. The culprit? A string I’d built by hand: "user-" + userId + "-profile". I’d assumed the userId was always a number, but TypeScript let it slip through as any somewhere upstream. I spent three hours tracing the bug, feeling like Neo dodging bullets in slow motion—except I was the one getting hit.

That frustration lit a fire: I wanted TypeScript to catch those mistakes before they left my editor. I started digging into the language’s lesser‑known corners, hunting for features that could turn my code from a fragile glass sculpture into a steel‑reinforced vault. What I found surprised me—features that many tutorials gloss over, yet they’re absolute game‑changers for safety and ergonomics.

The Revelation (The Insight)

1. Template Literal Types – Build Strings with Type‑Level Precision

Most of us know TypeScript’s union types for strings:

type Role = 'admin' | 'editor' | 'viewer';
Enter fullscreen mode Exit fullscreen mode

But what if you need to generate a set of permission strings like "admin:read" or "editor:write"? Writing them out by hand is tedious and error‑prone. Template literal types let you compose types the same way you compose strings at runtime.

Gotcha: If you’re not careful, the resulting union can explode combinatorially. Imagine type A = 'a' | 'b' and type B = 'x' | 'y' | 'z'; A & B as a template literal yields six members ('ax' | 'ay' | 'az' | 'bx' | 'by' | 'bz'). That’s usually what you want, but with larger unions you can unintentionally create hundreds of members, slowing down the IDE. Keep the base unions small, or split the logic into multiple steps.

Practical use case: Generating API endpoint routes from resource and action lists.

2. The satisfies Operator – Keep IntelliSense While Catching Excess Properties

TypeScript 4.9 introduced satisfies, a tiny operator that lets you validate that a value matches a type without widening it. Before satisfies, if you wrote:

const config = {
  apiUrl: 'https://example.com',
  timeout: 5000,
  retries: 3,
  debug: true,
} as const; // <-- we lose the exact shape if we annotate
Enter fullscreen mode Exit fullscreen mode

…and then tried to use config somewhere that expected a stricter shape, you’d get excess‑property errors or you’d lose autocomplete because you’d upcast to any or a generic type.

Gotcha: satisfies only works when you don’t add a type annotation that widens the value. If you write const config: ApiConfig = { … } as satisfies ApiConfig, you’ve already annotated, and the check becomes meaningless. The pattern is const x = { … } satisfies SomeType;.

Practical use case: Defining component props or configuration objects where you want both safety and rich autocomplete.

3. Exhaustive Checking with never – Never Miss a Union Case Again

Discriminated unions are a TypeScript superpower, but it’s easy to forget a case when you switch on the discriminant. The compiler will happily let you fall through, producing runtime bugs. The trick is to add a default branch that assigns the discriminant to never. If any case is missing, TypeScript will yell: Type 'SomeCase' is not assignable to type 'never'.

Gotcha: You need --strictNullChecks (or at least --noImplicitReturns) for this to work reliably; otherwise undefined can slip in and hide the mistake. Also, make sure the discriminant is a literal type, not a broader string or number.

Practical use case: State reducers, event handlers, or any scenario where you model a finite set of possibilities.

Wielding the Power (Code & Examples)

Template Literal Types – From Manual Unions to Auto‑Generated Safety

Before – the painful manual way:

type Permission =
  | 'admin:read'
  | 'admin:write'
  | 'admin:delete'
  | 'editor:read'
  | 'editor:write'
  | 'viewer:read';
Enter fullscreen mode Exit fullscreen mode

If we add a new role, we have to remember to add three new strings. Miss one? Runtime error waiting to happen.

After – let TypeScript do the work:

type Role = 'admin' | 'editor' | 'viewer';
type Action = 'read' | 'write' | 'delete';

type Permission = `${Role}:${Action}`;
// → "admin:read" | "admin:write" | "admin:delete"
//   | "editor:read" | "editor:write" | "editor:delete"
//   | "viewer:read" | "viewer:write" | "viewer:delete"
Enter fullscreen mode Exit fullscreen mode

Now adding a new role is as simple as extending Role. The compiler instantly updates Permission, and IntelliSense shows you every valid string.

Gotcha in action:

type BigRole = 
  | 'admin' | 'editor' | 'viewer' | 'moderator' | 'contributor' | 'subscriber';
type BigAction = 
  | 'read' | 'write' | 'delete' | 'publish' | 'archive' | 'restore';

type BigPermission = `${BigRole}:${BigAction}`;
// That's 6 × 6 = 36 members – still fine.
// Push it to 20 × 20 and you get 400 members; IDEs start to lag.
// Solution: split into smaller, domain‑specific unions.
Enter fullscreen mode Exit fullscreen mode

satisfies – Keep Your Objects Exact

Before – losing IntelliSense:

interface ApiConfig {
  apiUrl: string;
  timeout?: number;
  retries?: number;
  debug?: boolean;
}

// We annotate to catch excess props, but we lose literal values:
const config: ApiConfig = {
  apiUrl: 'https://example.com',
  timeout: 5000,
  retries: 3,
  debug: true,
  // oops – typo: `timeOut` would be caught, but we lose the exact `apiUrl` literal
};

function fetchData(c: ApiConfig) {
  // c.apiUrl is just `string`, not `"https://example.com"`
}
Enter fullscreen mode Exit fullscreen mode

After – saves the literal:

const config = {
  apiUrl: 'https://example.com',
  timeout: 5000,
  retries: 3,
  debug: true,
} satisfies ApiConfig;
// config.apiUrl is now the literal type "https://example.com"
// and if we added `timeOut: 5000`, we’d get an excess‑property error.
Enter fullscreen mode Exit fullscreen mode

Now we get the best of both worlds: excess‑property protection and autocomplete that knows the exact values we wrote.

Gotcha reminder:

// ❌ Wrong – annotation widens before satisfies checks:
const badConfig: ApiConfig = {
  apiUrl: 'https://example.com',
  timeout: 5000,
} satisfies ApiConfig; // still works, but the annotation already widened apiUrl to string
Enter fullscreen mode Exit fullscreen mode

Always place satisfies directly after the object literal, with no intervening type annotation.

Exhaustive never – Switches That Won’t Let You Slip

Before – missing case silently:

type Shape = 
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; sideLength: number }
  | { kind: 'triangle'; base: number; height: number };

function area(s: Shape): number {
  switch (s.kind) {
    case 'circle':
      return Math.PI * s.radius ** 2;
    case 'square':
      return s.sideLength ** 2;
    // Oops! Forgot `triangle`. No error, returns undefined at runtime.
  }
}
Enter fullscreen mode Exit fullscreen mode

After – never guards the default:

function area(s: Shape): number {
  switch (s.kind) {
    case 'circle':
      return Math.PI * s.radius ** 2;
    case 'square':
      return s.sideLength ** 2;
    case 'triangle':
      return (s.base * s.height) / 2;
    default:
      // The following line triggers a type error if any case is missing:
      const _exhaustiveCheck: never = s;
      return _exhaustiveCheck;
  }
}
Enter fullscreen mode Exit fullscreen mode

If we delete the triangle case, TypeScript complains:

Type '"triangle"' is not assignable to type 'never'.
Enter fullscreen mode Exit fullscreen mode

Now the compiler forces us to handle every variant.

Gotcha: Ensure the discriminant (kind here) is a literal union. If you wrote kind: string, the default branch would

Top comments (0)