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.
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;
}
}
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)
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>;
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'
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;
// }
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');
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)