I've been writing TypeScript for five years, and I'm still having "wait, it can do THAT?" moments. Not the fancy type gymnastics that make you feel clever, but the features that genuinely make everyday coding easier, faster, and less frustrating.
These aren't obscure tricks. They're practical features that have been sitting in my editor this whole time, waiting for me to discover them. Each one made me think: "Where has this been all my life?"
If you're like me—comfortable with TypeScript basics but sensing there's more—this is for you.
1. The satisfies Operator: Type Checking Without Losing Precision
For years, I wrestled with a frustrating trade-off: I could either get type safety OR preserve literal types, but not both.
The Old Way: Choose Your Pain
const config = {
apiUrl: "https://api.example.com",
timeout: 5000,
retries: 3
};
// Problem: TypeScript infers `apiUrl: string` instead of the literal
// If I typo the URL, no autocomplete helps me fix it
Okay, let's add a type annotation:
type Config = {
apiUrl: string;
timeout: number;
retries: number;
};
const config: Config = {
apiUrl: "https://api.example.com",
timeout: 5000,
retries: 3
};
// Problem: Still just `string` and `number`, literal types are gone
What about as const?
const config = {
apiUrl: "https://api.example.com",
timeout: 5000,
retries: 3
} as const;
// Problem: Now everything is readonly, and I have no type checking
// I could add a typo field and TypeScript won't complain
The New Way: satisfies to the Rescue
type Config = {
apiUrl: string;
timeout: number;
retries: number;
};
const config = {
apiUrl: "https://api.example.com",
timeout: 5000,
retries: 3
} satisfies Config;
// Magic! TypeScript checks the structure matches Config,
// but keeps the literal types:
// - apiUrl is "https://api.example.com", not string
// - timeout is 5000, not number
Now I get both type safety AND precise inference:
// Type error! Extra property
const badConfig = {
apiUrl: "https://api.example.com",
timeout: 5000,
retries: 3,
debugMode: true // Error: Object literal may only specify known properties
} satisfies Config;
// But I still have literals for autocomplete and refactoring
if (config.apiUrl === "https://api.example.com") { // TypeScript knows this is always true
// ...
}
Real-World Example: Route Definitions
This pattern shines for configuration objects:
type Route = {
path: string;
method: "GET" | "POST" | "PUT" | "DELETE";
handler: (req: any) => any;
};
const routes = {
getUser: {
path: "/users/:id",
method: "GET",
handler: (req) => ({ userId: req.params.id })
},
createUser: {
path: "/users",
method: "POST",
handler: (req) => ({ user: req.body })
}
} satisfies Record<string, Route>;
// Now you can do this with full autocomplete:
type RouteName = keyof typeof routes; // "getUser" | "createUser"
const getUserPath = routes.getUser.path; // Type is "/users/:id", not string!
When to use it: Anytime you want to validate an object's structure without widening its types. Especially for configs, route definitions, and lookup tables.
2. Const Assertions: The Feature I Ignored for Too Long
I saw as const in code and thought "that's just readonly, right?" Wrong. It's one of the most powerful features in TypeScript.
What It Actually Does
// Without as const
const colors = ["red", "green", "blue"];
// Type: string[]
// With as const
const colors = ["red", "green", "blue"] as const;
// Type: readonly ["red", "green", "blue"]
"So what?" Past me would ask. "It's just readonly."
But watch what happens:
type Color = typeof colors[number];
// Type: "red" | "green" | "blue"
// Now you have a union type derived from your data!
function setColor(color: Color) { /* ... */ }
setColor("red"); // ✓
setColor("purple"); // ✗ Type error!
The Pattern That Changed Everything
Before as const, I wrote types twice—once in code, once in types:
// The data
const STATUS_CODES = {
OK: 200,
NOT_FOUND: 404,
SERVER_ERROR: 500
};
// Manually maintaining a matching type
type StatusCode = 200 | 404 | 500;
This is maintenance hell. Add a status code? Update two places.
With as const:
const STATUS_CODES = {
OK: 200,
NOT_FOUND: 404,
SERVER_ERROR: 500
} as const;
type StatusCode = typeof STATUS_CODES[keyof typeof STATUS_CODES];
// Type: 200 | 404 | 500
// Single source of truth!
My Favorite Use Case: Type-Safe Constants
const PERMISSIONS = {
READ: "read",
WRITE: "write",
DELETE: "delete",
ADMIN: "admin"
} as const;
type Permission = typeof PERMISSIONS[keyof typeof PERMISSIONS];
// Type: "read" | "write" | "delete" | "admin"
const USER_ROLES = {
guest: [PERMISSIONS.READ],
user: [PERMISSIONS.READ, PERMISSIONS.WRITE],
admin: [PERMISSIONS.READ, PERMISSIONS.WRITE, PERMISSIONS.DELETE, PERMISSIONS.ADMIN]
} as const;
type Role = keyof typeof USER_ROLES;
// Type: "guest" | "user" | "admin"
function hasPermission(role: Role, permission: Permission): boolean {
return USER_ROLES[role].includes(permission);
}
The Tuple Trick
This one blew my mind:
// Without as const
const point = [10, 20];
// Type: number[] - could be any length!
// With as const
const point = [10, 20] as const;
// Type: readonly [10, 20] - exactly 2 elements with exact values
function useCoordinates<T extends readonly [number, number]>(coords: T) {
const [x, y] = coords;
// TypeScript knows there are exactly 2 elements
}
When to use it: Whenever you have data that should become a type. Status codes, permissions, configuration values, lookup tables.
3. The infer Keyword: Extracting Types from Types
I avoided infer for the longest time because it looked scary. Turns out, it's just pattern matching for types.
The Basic Idea
type GetReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
function getUser() {
return { id: 1, name: "John" };
}
type User = GetReturnType<typeof getUser>;
// Type: { id: number; name: string; }
That infer R is saying: "If T is a function, extract its return type and call it R."
Where This Actually Helps: Unwrapping Promises
type Unwrap<T> = T extends Promise<infer U> ? U : T;
async function fetchUser() {
return { id: 1, name: "John" };
}
type User = Unwrap<ReturnType<typeof fetchUser>>;
// Type: { id: number; name: string; }
// Not Promise<{ id: number; name: string; }>!
My Most-Used Pattern: Deep Property Access
type PropType<T, Path extends string> =
Path extends keyof T
? T[Path]
: Path extends `${infer K}.${infer Rest}`
? K extends keyof T
? PropType<T[K], Rest>
: never
: never;
type User = {
profile: {
settings: {
theme: "light" | "dark";
};
};
};
type Theme = PropType<User, "profile.settings.theme">;
// Type: "light" | "dark"
Okay, that one's getting fancy. But the point is: infer lets you extract types from other types. Once you understand the pattern, you'll start seeing uses everywhere.
When to use it: When you're writing utility types that need to extract or transform parts of other types. Start with the built-in utilities (ReturnType, Parameters) to see the pattern.
4. Template Literal Types: String Manipulation at the Type Level
I thought TypeScript was all about runtime JavaScript. Then I discovered you can manipulate strings in the type system.
Basic Example
type Greeting = `Hello, ${string}!`;
const valid: Greeting = "Hello, World!"; // ✓
const invalid: Greeting = "Hi there!"; // ✗ Type error
Where It Clicks: CSS-in-JS
type CSSUnit = "px" | "em" | "rem" | "%";
type Size = `${number}${CSSUnit}`;
function setWidth(width: Size) { /* ... */ }
setWidth("100px"); // ✓
setWidth("2em"); // ✓
setWidth("50%"); // ✓
setWidth("100"); // ✗ Type error!
setWidth("100pt"); // ✗ Type error!
The Pattern I Use Most: Event Names
type Entity = "user" | "post" | "comment";
type Action = "create" | "update" | "delete";
type EventName = `${Entity}:${Action}`;
// Type: "user:create" | "user:update" | "user:delete" |
// "post:create" | "post:update" | "post:delete" |
// "comment:create" | "comment:update" | "comment:delete"
const eventHandlers: Record<EventName, (data: any) => void> = {
"user:create": (data) => { /* ... */ },
"user:update": (data) => { /* ... */ },
// TypeScript ensures I handle all combinations!
};
Auto-generating Type-Safe APIs
This is where it gets powerful:
type HTTPMethod = "get" | "post" | "put" | "delete";
type Endpoint = "users" | "posts" | "comments";
type APIMethod = `${HTTPMethod}${Capitalize<Endpoint>}`;
// Type: "getUsers" | "postUsers" | "putUsers" | "deleteUsers" |
// "getPosts" | "postPosts" | "putPosts" | "deletePosts" | ...
type API = {
[K in APIMethod]: (params: any) => Promise<any>;
};
const api: API = {
getUsers: async (params) => { /* ... */ },
postUsers: async (params) => { /* ... */ },
// etc.
};
When to use it: When you have multiple enums that combine (HTTP methods + routes, entities + actions, etc.). Also great for CSS values, file paths, and any domain with structured strings.
5. Namespace Tricks: Organization Without the Overhead
I used to think namespaces were deprecated. Turns out, they're perfect for organizing related types and values.
The Problem: Type Clutter
// In a large codebase, this gets messy fast
type UserRole = "admin" | "user" | "guest";
type UserPermissions = string[];
type UserProfile = { /* ... */ };
type UserSettings = { /* ... */ };
const UserDefaults = { /* ... */ };
function validateUser() { /* ... */ }
The Solution: Namespaces as Containers
namespace User {
export type Role = "admin" | "user" | "guest";
export type Permissions = string[];
export type Profile = { /* ... */ };
export type Settings = { /* ... */ };
export const Defaults = {
role: "guest" as Role,
permissions: []
};
export function validate(user: Profile): boolean {
// ...
}
}
// Usage
function updateUser(role: User.Role, settings: User.Settings) {
// ...
}
Mix Types and Values
This is the killer feature: namespaces can contain both types and runtime values:
namespace API {
export type Response<T> = {
data: T;
status: number;
message: string;
};
export const BaseURL = "https://api.example.com";
export async function request<T>(endpoint: string): Promise<Response<T>> {
const response = await fetch(`${BaseURL}${endpoint}`);
return response.json();
}
}
// Everything is organized under one name
const response: API.Response<User> = await API.request("/users/1");
Pattern: Domain-Driven Organization
namespace Auth {
export type Credentials = { username: string; password: string };
export type Token = { value: string; expiresAt: Date };
export type Session = { user: User; token: Token };
export const TokenKey = "auth_token";
export function login(creds: Credentials): Promise<Session> { /* ... */ }
export function logout(): void { /* ... */ }
export function isAuthenticated(): boolean { /* ... */ }
}
namespace Payment {
export type Method = "card" | "paypal" | "crypto";
export type Transaction = { id: string; amount: number; method: Method };
export type Receipt = { transaction: Transaction; timestamp: Date };
export async function process(amount: number, method: Method): Promise<Receipt> { /* ... */ }
export function refund(transactionId: string): Promise<void> { /* ... */ }
}
// Clear, organized, discoverable
Auth.login({ username: "user", password: "pass" });
Payment.process(100, "card");
When to use it: When you have related types and functions that belong together. Great for API clients, domain logic, and utility libraries. Just don't overuse—they're for organization, not encapsulation.
6. The satisfies + as const Combo: Type Safety Nirvana
Here's where it all comes together. Combine satisfies and as const for the ultimate type safety:
type Theme = {
colors: {
primary: string;
secondary: string;
};
spacing: {
small: number;
medium: number;
large: number;
};
};
const theme = {
colors: {
primary: "#007bff",
secondary: "#6c757d"
},
spacing: {
small: 4,
medium: 8,
large: 16
}
} as const satisfies Theme;
// You get:
// 1. Type checking (satisfies Theme)
// 2. Literal types (as const)
// 3. Readonly protection (as const)
type PrimaryColor = typeof theme.colors.primary; // "#007bff", not string
type SpacingSize = keyof typeof theme.spacing; // "small" | "medium" | "large"
Real Example: Feature Flags
type FeatureFlag = {
enabled: boolean;
rolloutPercentage?: number;
};
const features = {
newDashboard: { enabled: true, rolloutPercentage: 50 },
advancedSearch: { enabled: false },
darkMode: { enabled: true }
} as const satisfies Record<string, FeatureFlag>;
type FeatureName = keyof typeof features; // "newDashboard" | "advancedSearch" | "darkMode"
function isFeatureEnabled(name: FeatureName): boolean {
return features[name].enabled;
}
// Full autocomplete, type safety, and single source of truth!
7. Utility Types You're Probably Not Using
TypeScript ships with powerful utility types. I ignored them for years, writing my own versions of things that already existed.
Awaited<T>: Unwrap Promises (Without infer)
type User = Awaited<ReturnType<typeof fetchUser>>;
async function fetchUser() {
return { id: 1, name: "John" };
}
// User is { id: number; name: string }, not Promise<...>
NonNullable<T>: Filter Out null and undefined
type MaybeString = string | null | undefined;
type DefinitelyString = NonNullable<MaybeString>; // string
function processValue(value: MaybeString) {
if (value) {
// TypeScript narrows to string here
const upper: DefinitelyString = value;
}
}
Extract<T, U> and Exclude<T, U>: Union Type Filtering
type Status = "pending" | "approved" | "rejected" | "cancelled";
type ActiveStatus = Exclude<Status, "rejected" | "cancelled">;
// Type: "pending" | "approved"
type FinalStatus = Extract<Status, "approved" | "rejected">;
// Type: "approved" | "rejected"
Parameters<T> and ConstructorParameters<T>: Function Introspection
function createUser(name: string, age: number, role: "admin" | "user") {
return { name, age, role };
}
type CreateUserParams = Parameters<typeof createUser>;
// Type: [name: string, age: number, role: "admin" | "user"]
// Now you can use the same types elsewhere:
function logUserCreation(...args: CreateUserParams) {
console.log("Creating user with:", args);
createUser(...args);
}
8. The tsconfig.json Settings That Actually Matter
I used to cargo-cult my tsconfig from project to project. Here are the settings I wish I'd understood sooner:
strict: true (Just Do It)
Stop fighting this. Turn it on from day one. The short-term pain is worth it.
{
"compilerOptions": {
"strict": true
}
}
noUncheckedIndexedAccess: Prevent the Classic Bug
// Without this setting:
const dict: Record<string, string> = {};
const value = dict["nonexistent"]; // Type: string (LIES!)
console.log(value.toUpperCase()); // Runtime error!
// With "noUncheckedIndexedAccess": true
const value = dict["nonexistent"]; // Type: string | undefined
console.log(value.toUpperCase()); // Type error - must check for undefined
exactOptionalPropertyTypes: Optional vs Undefined
type User = {
name: string;
age?: number; // Can be undefined or missing
};
// Without exactOptionalPropertyTypes:
const user1: User = { name: "John", age: undefined }; // Allowed
// With exactOptionalPropertyTypes:
const user2: User = { name: "John", age: undefined }; // Error!
const user3: User = { name: "John" }; // This is the only way
noPropertyAccessFromIndexSignature: Catch Typos
type Config = {
apiUrl: string;
[key: string]: string;
};
const config: Config = { apiUrl: "https://api.com" };
// Without this setting:
console.log(config.apiUrll); // No error, returns undefined
// With "noPropertyAccessFromIndexSignature": true
console.log(config.apiUrll); // Type error - must use bracket notation
console.log(config["apiUrll"]); // OK (you're explicitly opting into dynamic access)
What I'd Tell My Past Self
Learn
satisfiesfirst. It solves the most common pain point: wanting both validation and precise types.Use
as constliberally. Any time you have data that should become a type, slapas conston it.Don't fear advanced types. Start with
ReturnTypeandtypeof, then explore from there. The pattern repeats.Namespaces aren't dead. They're perfect for organizing related types and functions.
Read the tsconfig docs. Seriously. Five minutes of reading could prevent hours of debugging.
The type system is your friend. If the types feel wrong, your code might be wrong. Listen to them.
The Compounding Effect
Each of these features individually is nice. Together, they transform how you write TypeScript:
// Everything we learned, combined:
namespace API {
const ENDPOINTS = {
users: "/api/users",
posts: "/api/posts",
comments: "/api/comments"
} as const satisfies Record<string, `/${string}`>;
type Endpoint = keyof typeof ENDPOINTS;
export type Response<T> = {
data: T;
status: number;
error?: string;
};
export async function get<E extends Endpoint>(
endpoint: E
): Promise<Response<unknown>> {
const url = ENDPOINTS[endpoint];
const response = await fetch(url);
return response.json();
}
}
// Type-safe, autocomplete everywhere, single source of truth
const response = await API.get("users");
Your Turn
These features were hiding in plain sight in my editor for years. Each one I discovered made me think: "I could have saved so much time if I'd known this earlier."
You don't need to learn them all at once. Pick one that solves a pain point you have today. Use it for a week. Then come back and grab another.
Your code will thank you. Your team will thank you. And most importantly, future you will thank you.
What TypeScript features changed the game for you? I'd love to hear what you wish you'd learned sooner.
Top comments (0)