Hidden Gems in TypeScript - Short Hands, Aliases, and Underutilized Built-ins That Save You Time
Source: Dev.to
TL;DR
- Leverage compact utility types and type aliases (including branded types) to cut boilerplate and prevent mix‑ups.
- Use mapped types, conditional types, and
as constto create ergonomic, expressive APIs. - Tap into built‑ins like
Awaited, template literal types, and discriminated unions for safer, clearer code. - See concrete patterns: deep partial updates, branded IDs, deep readonly configurations, and discriminated action handling.
- Each example includes a minimal, runnable snippet you can copy‑paste into your project.
Prerequisites
- TypeScript 4.1+ for template literal types; 4.5+ for
Awaited; 4.x+ generally fine for most features here. - Basic familiarity with TypeScript types, interfaces, and generics.
- A Node/tsconfig project to try snippets locally (optional: a small repo you can clone to test).
What You’ll Learn
- Practical type tricks:
DeepPartial,DeepReadonly,Mutable, branded IDs. - Short hands and ergonomics:
as const,inferin conditional types,ReturnType/Parameters. - Useful built‑ins you may not fully leverage:
Awaited, template literal types, discriminated unions, opaque/branding patterns. - Real‑world patterns: safe config loading, typed IDs, ergonomic API shapes, and robust error handling.
Utility Types You Might Be Overlooking
DeepPartial: Partial Structures at All Nesting Levels
Need to update nested objects without specifying every field? DeepPartial makes all properties optional recursively:
type DeepPartial = {
[P in keyof T]?: T[P] extends object ? DeepPartial : T[P];
};
// Usage
type User = {
id: string;
profile: {
name: string;
bio?: string;
};
roles: string[];
};
type UpdateUser = DeepPartial;
// Accepts e.g.:
// { profile: { name?: string }, roles?: string[] }
NonNullable in Practice
Extract non‑nullable values to ensure type safety:
type SafeProp = T[K] extends null | undefined ? never : T[K];
// Example constraint
function getName(obj: T): string {
return obj.name ?? "anonymous";
}
Type Aliases and Branded Types
Branded / Opaque IDs to Prevent Mixing IDs
Ever passed the wrong ID to a function? Branded types prevent this at compile time:
type UserId = string & { __brand?: "UserId" };
type OrderId = string & { __brand?: "OrderId" };
function asUserId(id: string): UserId {
return id as UserId;
}
function getUser(userId: UserId) {
// runtime fetch by id
}
Benefit: Runtime value remains a string, but the compiler enforces correct ID usage.
When to Use Type Aliases vs Interfaces
- Interfaces: public API shapes and object literals you intend to extend.
- Aliases: unions, primitives, or branded types.
Mapped Types and Conditional Tricks
Mutable Pattern
Remove readonly modifiers when you need mutability:
type Mutable = { -readonly [P in keyof T]: T[P] };
type ReadonlyPoint = Readonly;
type Point = Mutable;
const p: Point = { x: 1, y: 2 };
p.x = 3; // ✅ allowed
DeepReadonly (Read‑Only Everywhere)
Lock down entire configuration objects:
type DeepReadonly = {
readonly [P in keyof T]: T[P] extends object ? DeepReadonly : T[P];
};
type Config = {
server: { host: string; port: number };
features: string[];
};
type SafeConfig = DeepReadonly;
Distributive Conditional Tricks
type IsStringLike = T extends string ? true : false;
type A = IsStringLike; // true | false
Short Hands and Ergonomic Syntax
as const for Literal Narrowing and Discriminated Unions
const FETCH_USERS = { type: "FETCH_USERS" } as const;
type Action = typeof FETCH_USERS;
type Response =
| { status: "ok"; data: string[] }
| { status: "error"; error: string };
function handle(res: Response) {
if (res.status === "ok") {
// res.data is string[]
}
}
infer in Conditional Types
Extract types from arrays or other generic structures:
type ElementType = T extends (infer U)[] ? U : T;
type T1 = ElementType; // string
type T2 = ElementType; // number
Awaited for Unwrapping Promises
async function fetchData(): Promise {
return { ok: true };
}
type Data = Awaited>; // { ok: boolean }
Template Literal Types for Chemistry Between Strings and Types
type RoutePath = `/users/${string}` | `/projects/${string}`;
function navigate(path: RoutePath) {
// runtime navigation
}
Built‑ins You May Not Fully Leverage
- Opaque branding via intersection (as shown with
UserId/OrderId). - Template literal types to encode string shapes in types.
- Yielding with Generator types (advanced exercise).
- AsyncReturnType pattern (a predecessor to
Awaitedin 4.x).
Note: Some of these require careful design to avoid over‑engineering—use them where they solve a real pain point.
Practical Patterns with Concrete, Minimal Examples
Example 1: Safe Configuration Loader
type Config = {
port?: number;
host?: string;
};
type RequiredConfig = Required;
function loadConfig(input: Partial): Config {
const defaultConfig: Config = { port: 3000, host: "localhost" };
return { ...defaultConfig, ...input };
}
// Usage
const cfg = loadConfig({ host: "example.com" });
Example 2: Discriminated Union for API Responses
type ApiResponse =
| { ok: true; data: string[] }
| { ok: false; error: string };
function handleResponse(r: ApiResponse) {
if (r.ok) {
// r.data is string[]
console.log("Got data", r.data);
} else {
console.error("API error", r.error);
}
}
Example 3: Function Ergonomics with Generics and ReturnType
function wrap(value: T) {
return { value };
}
type Wrapped = ReturnType>;
Pitfalls and Tips
- Complexity vs. readability: Advanced types are powerful but can obscure intent.
- Prefer clear intent before cleverness; add comments documenting the rationale for branding or
DeepPartialtypes. - When in doubt, validate with a small runtime test to ensure the type‑level trick aligns with runtime semantics.
- Keep snippets minimal and focused; readers often skim dense type theory.
Real‑World Integration Tips
- Introduce in‑code comments near branded types to explain safety guarantees.
- If you’re adding a branded ID pattern, audit runtime code to ensure IDs are consistently created and consumed.