DEV Community

TJ Coding
TJ Coding

Posted on

7 TypeScript Tricks That Feel Illegal to Use

If you’re reading this, you likely know your way around interface, type, and generics. You probably have strict mode enabled. But TypeScript is capable of much more than just catching typos or ensuring a prop exists.

When used at a "pro" level, TypeScript stops being a linter and starts being a documentation engine and an architectural guardrail. Sometimes, you even have to abuse the type system to get the safety you really want.

Here are 7 patterns—ranging from modern best practices to "dark arts" hacks—to elevate your TypeScript mastery.

1. The satisfies Operator

Introduced in TypeScript 4.9, satisfies is a game-changer. It allows you to validate that an expression matches a type without changing the resulting type of that expression.

When you use a standard type annotation (e.g., const config: Config = ...), you lose specific inference. satisfies keeps the inference while enforcing the contract.

type Colors = "red" | "green" | "blue";
type RGB = [number, number, number];

const palette = {
  red: [255, 0, 0],
  green: "#00ff00",
  blue: [0, 0, 255]
} satisfies Record<Colors, string | RGB>;

// ✅ TS knows 'green' is a string, so this works:
palette.green.toUpperCase();

// ✅ TS knows 'red' is a tuple, so this works:
palette.red.map(n => n * 2);

// ❌ If we had used 'const palette: Record<...>', both lines above would error
// because TS would treat every property as 'string | RGB' loosely.
Enter fullscreen mode Exit fullscreen mode

2. Zero-Cost Exhaustiveness Checking

When using switch statements on a union type, how do you ensure you haven't missed a case? You leverage the never type.

In the past, we used to assign the variable to a dummy never variable. But a cleaner, more modern approach is using satisfies never in the default block. It performs the check without creating unused variables that Linters hate.

type Status = "loading" | "success" | "error";

function getStatusMessage(status: Status) {
  switch (status) {
    case "loading": return "Please wait...";
    case "success": return "Done!";
    case "error": return "Something went wrong.";
    default:
      // If you add "idle" to Status, this line will turn red.
      status satisfies never; 
  }
}
Enter fullscreen mode Exit fullscreen mode

3. Namespace-Style Template Literals

You can generate complex string types based on other types. This is incredibly powerful for Redux action types, event handlers, or strict string formatting that mimics file paths or namespaces.

type Domain = "User" | "Post" | "Comment";
type Action = "create" | "delete" | "update";

// Automatically generates: "User/create" | "User/delete" | "Post/create" ...
type AppEvent = `${Domain}/${Action}`;

function dispatch(event: AppEvent) {
  console.log(event);
}

dispatch("User/create"); // ✅
dispatch("Post/create"); // ✅
dispatch("User_create"); // ❌ Error (Wrong delimiter)
dispatch("Auth/login");  // ❌ Error (Invalid Domain)
Enter fullscreen mode Exit fullscreen mode

4. The Prettify Helper (The "Tooltip Hack")

When you intersect multiple types (e.g., User & { role: string }), TypeScript's hover tooltip often shows the ugly, raw intersection Type A & Type B instead of the resulting object shape.

You can force TypeScript to "resolve" the UI for your tooltip using this simple helper. It doesn't change the runtime behavior, but it drastically improves DX.

// The Helper
type Prettify<T> = {
  [K in keyof T]: T[K];
} & {};

type User = { id: string; name: string };
type Admin = User & { permissions: string[] };

// Hovering over 'PrettyAdmin' shows the full object structure:
// { id: string; name: string; permissions: string[] }
type PrettyAdmin = Prettify<Admin>; 
Enter fullscreen mode Exit fullscreen mode

5. Branded Primitives (Nominal Typing)

TypeScript is structurally typed (if it looks like a duck, it's a duck). But sometimes, a UserId string should not be assignable to a PostId string, even if both are just strings.

We can "brand" primitive types to simulate Nominal Typing. This prevents accidental ID swapping.

declare const __brand: unique symbol;
type Brand<T, B> = T & { [__brand]: B };

type UserId = Brand<string, "UserId">;
type PostId = Brand<string, "PostId">;

const myUser = "user_123" as UserId;
const myPost = "post_456" as PostId;

function findUser(id: UserId) { /* ... */ }

findUser(myUser); // ✅ OK
findUser(myPost); // ❌ Error: Type 'PostId' is not assignable to 'UserId'
Enter fullscreen mode Exit fullscreen mode

6. Key Remapping via as

You can rename keys while mapping over a type. This is excellent for creating derived types that follow specific naming conventions, like automatically generating setter methods for a state object.

type Person = {
  name: string;
  age: number;
  location: string;
};

// Create a type for specific 'set' functions
type Setters<T> = {
    [K in keyof T as `set${Capitalize<string & K>}`]: (value: T[K]) => void;
};

// Resulting Type:
// {
//   setName: (value: string) => void;
//   setAge: (value: number) => void;
//   setLocation: (value: string) => void;
// }
Enter fullscreen mode Exit fullscreen mode

7. Block Inference with NoInfer (TS 5.4+)

Sometimes TypeScript is too smart and widens types when you don't want it to.

If you have a function where one argument should define the generic T, and the second argument must strictly match it, TypeScript will often try to find a common union type between them. NoInfer tells TypeScript: "Do not use this specific argument to infer T."

function createConfig<T extends string>(
  validOptions: T[], 
  defaultOption: NoInfer<T> // <--- Magic happens here
) {
  return { validOptions, defaultOption };
}

// ✅ Valid
createConfig(['dark', 'light'], 'dark');

// ❌ Error
// Without NoInfer, TS would infer T as "dark" | "light" | "blue" and allow this.
// With NoInfer, T is locked to the array, so "blue" is rightly caught as an error.
createConfig(['dark', 'light'], 'blue');
Enter fullscreen mode Exit fullscreen mode

Final Thoughts

The goal of advanced TypeScript isn't to write the most complex generics possible—it's to write code that explains itself. By using tools like satisfies, Prettify, and NoInfer, you shift the burden of context and memory from your brain to the compiler.

Which of these patterns do you use most often? Let me know in the comments!

Top comments (0)