DEV Community

Timevolt
Timevolt

Posted on

TypeScript tips to write safer, cleaner code: Mastering the Force

The Quest Begins (The “Why”)

I still remember the first time I opened a pull request that looked innocent enough—just a few UI tweaks—but the CI pipeline exploded with a cascade of Property 'foo' does not exist on type 'never' errors. I spent two hours staring at the same line, convinced I’d missed a semicolon, when in reality the problem was that TypeScript had widened my string literal to string and lost the exact value I needed.

That moment felt like trying to defeat a final boss with a wooden sword. I knew there had to be a sharper blade hidden in the language’s arsenal—something most tutorials gloss over, but that can turn a frustrating bug hunt into a satisfying “I‑got‑this” victory. So I embarked on a quest to uncover those hidden gems, and I’m excited to share the three surprises that leveled up my TypeScript game.

The Revelation (The Insight)

1. as const – Locking Literals in Their Tracks

The gotcha: By default, TypeScript widens let foo = "open" to the type string. If you later try to use foo as a discriminating union member, the compiler says “nope, could be any string.” The fix? Throw as const on the literal (or the whole object) and tell TypeScript, “Hey, keep this exactly as I wrote it.”

Why it matters: It gives you true literal types, which unlock exhaustiveness checks, prevents accidental string typos, and lets you rely on the compiler to catch impossible states.

Practical use case: Imagine you’re defining a set of allowed event names for a custom UI component. You want the component to accept only "open" | "close" | "toggle" and nothing else.

2. The satisfies Operator – Having Your Cake and Eating It Too

The gotcha: Before TypeScript 4.9, if you wanted to catch excess properties on an object while still preserving the inferred type for IntelliSense, you’d either cast with as (losing safety) or create a separate type annotation (duplicating work). The satisfies operator solves this by letting you verify that a value conforms to a shape without changing its inferred type.

Why it matters: You get the best of both worlds—compile‑time safety for extra fields and full autocompletion for the properties you actually care about.

Practical use case: Define a theme object where you want to guarantee that every key is a valid CSS variable name, but you still want the exact values (like "#ff0000" or "12px" ) to stay typed for later use.

3. Template Literal Types – Building Unions from Strings

The gotcha: Manually writing a union of all possible event names ("click" | "dblclick" | "mouseenter" …) is tedious and error‑prone. If you rename an event in one place, you have to remember to update the union elsewhere. Template literal types let you generate those unions algorithmically from other literal types.

Why it matters: You get a single source of truth. Change the base list, and the derived unions update automatically—no more “I forgot to add the new event” bugs.

Practical use case: Create a type-safe event emitter where the on method only accepts events that actually exist in your component’s API.

Wielding the Power (Code & Examples)

🎯 Example 1: as const for Literal Tuples

Before – the struggle:

const sizes = ["small", "medium", "large"]; // inferred as string[]
type Size = typeof sizes[number]; // string! 😱
Enter fullscreen mode Exit fullscreen mode

If you later try to use Size in a switch, the compiler warns you about missing cases because it thinks Size could be any string.

After – the victory:

const sizes = ["small", "medium", "large"] as const;
//        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// Now sizes is a readonly tuple: readonly ["small", "medium", "large"]
type Size = typeof sizes[number]; // "small" | "medium" | "large"

function handleSize(s: Size) {
  switch (s) {
    case "small":
      // ✅ exhaustiveness check works
      break;
    case "medium":
      break;
    case "large":
      break;
    // default is unnecessary – TS knows all cases are covered
  }
}
Enter fullscreen mode Exit fullscreen mode

Why this makes you a better coder: You no longer rely on unit tests to catch a typo like "smal"; the compiler fails instantly. Plus, readonly tuples prevent accidental pushes, giving you immutable‑by‑default data structures—a small win for functional‑style code.

🎯 Example 2: satisfies for Safe Configuration

Before – the struggle:

const theme = {
  "--color-primary": "#0066ff",
  "--color-secondary": "#ff6600",
  "--font-size": "16px",
  "--invalid-prop": 42, // oops! we didn’t mean to add this
};

type Theme = typeof theme; // inferred, but we lose the chance to catch extra keys
Enter fullscreen mode Exit fullscreen mode

If we later consume theme expecting only strings, the number 42 slips through until runtime.

After – the victory:

type ThemeValues = string | number; // we allow both, but we want to catch wrong keys

const theme = {
  "--color-primary": "#0066ff",
  "--color-secondary": "#ff6600",
  "--font-size": "16px",
  "--invalid-prop": 42, // 🚫 error: Type '{ "--invalid-prop": number; ... }' is not assignable to type 'Theme'
} satisfies Record<string, ThemeValues>;
//        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// The `satisfies` clause checks the object against Record<string, ThemeValues>
// but leaves the actual type of `theme` as the original literal object.

// Now we can still use the exact keys for autocompletion:
const primary = theme["--color-primary"]; // type is "#0066ff" (literal)
Enter fullscreen mode Exit fullscreen mode

Why this makes you a better coder: You get instant feedback when someone adds a property that doesn’t fit the allowed value types, yet you don’t lose the rich literal types that power autocomplete. It’s like having a guardrail that doesn’t obstruct your view.

🎯 Example 3: Template Literal Types for Event Names

Before – the struggle:

type EventName = "click" | "dblclick" | "mouseenter" | "mouseleave";
// ...and we have to remember to update this union whenever we add a new event.
Enter fullscreen mode Exit fullscreen mode

If we forget to add "focus" to the union, the on method will happily accept it, leading to a silent bug.

After – the victory:

// Base list of DOM events we support – single source of truth
const baseEvents = ["click", "dblclick", "mouseenter", "mouseleave", "focus"] as const;
type BaseEvent = typeof baseEvents[number]; // "click" | "dblclick" | "mouseenter" | "mouseleave" | "focus"

// Build prefixed event names like "ui:click"
type UiEvent = `ui:${BaseEvent}`;
// => "ui:click" | "ui:dblclick" | "ui:mouseenter" | "ui:mouseleave" | "ui:focus"

function on<E extends UiEvent>(event: E, handler: (e: Event) => void) {
  // implementation…
}

// Usage:
on("ui:click", () => console.log("clicked")); // ✅
on("ui:focus", () => console.log("focused")); // ✅
on("ui:blur", () => console.log("blurred")); // ❌ Error: '"ui:blur"' is not assignable to type 'UiEvent'
Enter fullscreen mode Exit fullscreen mode

Why this makes you a better coder: The event list is defined once. Add a new base event, and all derived unions (UiEvent, maybe also data:${BaseEvent}, etc.) update automatically. No more “I forgot to add the new event” bugs, and your IDE will suggest the correct strings as you type.

Why This New Power Matters

Mastering these three features feels like discovering hidden shortcuts in a sprawling RPG map. Suddenly, you’re not just grinding through type errors—you’re anticipating them, shaping your code so the compiler does the heavy lifting.

  • as const gives you immutable, literal‑level safety, turning “maybe a string” into “definitely this exact word.”
  • satisfies lets you validate shape without sacrificing the rich IntelliSense that makes development joyful.
  • Template literal types turn repetitive string unions into declarative, maintainable formulas.

When you combine them, you write code that’s self‑documenting, refactor‑friendly, and bug‑resistant—the hallmarks of a senior developer who trusts the type system instead of fighting it.

Your Turn: The Challenge

Pick a piece of your current project where you’re using plain strings or objects for configuration (think feature flags, API payloads, or theme values). Try applying one of the three techniques above:

  • Wrap a literal array with as const and see how your union types tighten.
  • Replace a plain as cast with satisfies to catch excess properties while keeping lint‑friendly autocompletion.
  • Derive a new type from a base list using template literal types and watch your IDE autocomplete the exact values you need.

Share your before/after snippets in the comments—I love seeing how these tricks turn everyday code into something a bit more magical.

Now go forth, young TypeScript wanderer, and may the Force (or at least a solid type system) be with you! 🚀

Top comments (0)