Introduction
Small conveniences can make TypeScript feel almost unfairly powerful. After using it daily for years, it’s easy to stop noticing the tiny quality-of-life features that quietly keep code clean and safe. But when you step back and look closely, you realize how much TypeScript reduces friction, prevents bugs, and trims boilerplate in ways that are hard to replicate elsewhere.
These aren’t flashy, academic type-theory tricks. They’re practical capabilities that make everyday code remarkably tidy, reliable, and low-maintenance—often so subtle you only appreciate them when you’re forced to write more defensive code than you’d like.
Below are 12 of the most underrated TypeScript features, each with real examples and a quick explanation of why they matter.
1. Discriminated Unions – Making Switch Statements Smarter
Discriminated unions are the answer to messy conditional logic, especially when dealing with APIs or data that can take multiple shapes. A discriminated union is a union of object types that have a common discriminant property (typically a literal field that indicates the variant type). This lets TypeScript understand exactly which shape it’s dealing with after you check that property.
For example, imagine an API returns a list of content elements of various types:
// Different element shapes indicated by a common "type" field
type Element =
| { type: "title"; content: string }
| { type: "image"; path: string }
| { type: "text"; content: string };
function render(element: Element) {
switch (element.type) {
case "title":
return `<h1>${element.content}</h1>`;
case "image":
return `<img src="${element.path}" />`;
case "text":
return `<p>${element.content}</p>`;
}
}
In the render function, the switch on element.type intelligently narrows the type. Inside each case, TypeScript knows the exact shape: e.g. in the "image" case, element has a path property available. There’s no need for tons of manual type casting or optional chaining. Each case is type-checked exhaustively. Notice we didn’t even need a default case – TypeScript ensures we’ve handled all possible Element.type values.
Discriminated unions keep code clean and trustworthy. It’s a simple pattern that dramatically reduces errors.
2. as const – Bringing Literal Types into Order
Have you ever defined an array of values, only for TypeScript to collapse it into a generic type like string[]?
const roles = ["admin", "user", "guest"];// By default, roles is inferred as string[]
But often you want those exact values as types. The solution is as const:
const roles = ["admin", "user", "guest"] as const;
type Role = typeof roles[number];
// Role is now "admin" | "user" | "guest"
This keeps a single source of truth for allowed values—no duplication, fewer typos, and often no need for enums.
3. Tuples – More Powerful Than You Think
Tuples are fixed-length arrays with known element types:
type Pair = [string, number];
type Coordinate = [number, number];
type UserInfo = [string, number, boolean];
const pointA: Coordinate = [10, 20];
const user: UserInfo = ["Alice", 25, true];
TypeScript errors if you add extra elements or use the wrong types.
4. Template Literal Types – Dynamic String Types Made Easy
Generate string unions from patterns:
type Lang = "en" | "vi";
type Field = "title" | "description";
type TranslationKey = `${Lang}_${Field}`; // "en_title" | "en_description" | "vi_title" | "vi_description"
Great for keys, conventions, and event names:
type Status = "loading" | "success" | "error";
type Action = "fetch" | "update" | "delete";
type ActionStatus = `${Action}_${Status}`;
5. Key Extraction with keyof and typeof – A Powerful Combo
Derive string unions directly from object keys:
const colors = {
primary: "#000000",
secondary: "#ffffff",
};
type ColorName = keyof typeof colors; // "primary" | "secondary"
Useful for config keys:
const config = {
apiUrl: "<https://api.example.com>",
timeout: 5000,
retryCount: 3,
};
type ConfigKey = keyof typeof config;
function getConfig(key: ConfigKey) {
return config[key];
}
6. Strict Mode – Tough Love for Safer Code
Enable strictness gradually during migration, then enforce it for stronger guarantees:
{ "compilerOptions": { "strict": true } }
TypeScript’s strict mode is a bundle of compiler settings that make the type system much more rigorous. When enabled, it forces you to handle null/undefined cases, catches implicit any types, and generally doesn’t let you get away with the “loose” coding that plain JavaScript permits. This can be intimidating at first – especially when migrating a large JavaScript codebase to TypeScript – which is why some developers start with strict mode off. But the beauty of TypeScript is that you don’t have to go all-in immediately.
You can begin with "strict": false in your tsconfig.json to get started, and gradually add types and fix issues at your own pace. As your project becomes more robustly typed, flipping "strict": true will turn on those additional checks and immediately highlight any remaining unsound bits of code. The TypeScript team designed it this way to make the language adoptable – you opt into strictness as you and your team become comfortable, rather than facing a wall of errors on day one.
7. Type-Safe Array Access (noUncheckedIndexedAccess) – Catch Out-of-Bounds Bugs Early
Make indexed access reflect reality:
{ "compilerOptions": { "noUncheckedIndexedAccess": true } }
Now:
const list = ["a", "b", "c"];
const item = list[10]; // string | undefined
if (item !== undefined) {
console.log(item.toUpperCase());
}
Advantages:
- Prevents undefined crashes early by making out-of-bounds access typed as
T | undefined. - Forces safer handling (guards/defaults) exactly where the risk exists.
- Matches runtime reality, especially for dynamic indexes and API-driven arrays.
8. Type Augmentation – Extending Existing Types
Extend globals or library types safely:
declare global {
interface Window {
appVersion: string; }
}
window.appVersion = "1.0.0";
Example with Express:
declare global {
namespace Express {
interface Request {
currentUser?: { id: string; name: string }; }
}
}
Advantages:
- Makes real-world JS patterns type-safe: If your app adds fields to globals (
window,process.env) or attaches data to library objects (likereq.user), augmentation makes those patterns compile cleanly and safely. - Improves developer experience: Autocomplete, go-to-definition, and refactoring all work for your added properties—no more repetitive casting like
as any. - Eliminates scattered type assertions: Instead of sprinkling
as unknown as ...everywhere, you centralize the type truth in one place. - Keeps third-party types aligned with your app: Extend framework/library types (Express, NextAuth, Vue, Zustand, etc.) to reflect your actual runtime shape without forking type packages.
- Reduces runtime bugs: When teams consistently use the augmented types, missing fields get caught earlier (e.g.,
req.currentUserbeing optional forces checks). - Supports gradual typing: You can start by augmenting only what you need today, then refine later (e.g., make
currentUsernon-optional once middleware guarantees it).
9. unknown – The Safer Alternative to any
Prefer unknown to force narrowing before use:
let data: unknown = "Hello";
console.log(data.trim()); // Error
if (typeof data === "string") {
console.log(data.trim());
}
Advantages:
- Safer than
any: you can’t accidentally call methods without narrowing first. - Encourages explicit validation, which is ideal for
JSON.parse, user input, and third-party data. - Keeps type safety intact while still allowing “I don’t know the shape yet” scenarios.
10. The satisfies Operator – Validate Types Without Losing Specifics
Validate shape while preserving literals:
const routes = {
home: "/",
profile: "/users/:id",
} satisfies Record<string, `/${string}`>;
Advantages:
- Validates shape without losing literals, preserving autocomplete and precise types.
- Catches config mistakes (wrong keys/values) at compile time while keeping the object flexible.
- Avoids unsafe
asassertions, so errors don’t get silently hidden.
11. Assertion Functions – Custom Type Guards with asserts
Create checks that narrow types after they pass:
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== "string") {
throw new Error("Expected a string");
}
}
function processName(name: unknown) {
assertIsString(name);
console.log(name.toUpperCase());
}
Advantages:
- Turns runtime checks into type narrowing, reducing repeated
ifchecks. - Centralizes validation logic, improving consistency across the codebase.
- Makes “guaranteed after check” code clean (especially for security/auth/invariants).
12. Utility Types – Built-in Helpers for Common Type Transformations
Use built-ins like Omit, Exclude, and ReturnType to reduce boilerplate:
interface Person {
name: string;
age: number;
location: string;
}
type PersonWithoutLocation = Omit<Person, "location">;
type Status = "success" | "error" | "loading";
type VisibleStatus = Exclude<Status, "loading">;
function getUser() {
return { id: 42, name: "Alice" };
}
type User = ReturnType<typeof getUser>;
Advantages:
- Reduces boilerplate for common transformations (e.g.,
Omit,Pick,ReturnType). - Keeps types DRY and in sync with real code and data structures.
- Improves readability by expressing intent (“exclude these keys”) directly in the type.
Conclusion
TypeScript’s real power is in the small, practical features that quietly prevent bugs, reduce boilerplate, and keep codebases maintainable. If you adopt these patterns—especially in larger projects—you’ll feel the difference quickly, and you’ll miss them the moment you’re without them.