15 TypeScript Tips and Tricks for Cleaner Code (2026)
TypeScript has evolved significantly since its release, introducing powerful features that go far beyond basic type annotations. Whether you're building enterprise applications or open-source libraries, these 15 tips will help you write safer, more maintainable TypeScript code.
1. Use satisfies for Type-Safe Object Literals
The satisfies operator (TypeScript 4.9+) lets you validate that a value matches a type without widening its type. This is perfect when you want type checking without losing literal types.
type Color = "red" | "green" | "blue";
interface Theme {
primary: Color;
secondary: Color;
accent?: Color;
}
// Without satisfies: loses literal types
const theme1: Theme = {
primary: "red",
secondary: "blue"
};
// theme1.primary is Color, not "red"
// With satisfies: keeps literal types
const theme2 = {
primary: "red",
secondary: "blue"
} satisfies Theme;
// theme2.primary is "red", theme2.secondary is "blue"
// But TypeScript still validates it matches Theme!
Pro tip: Use satisfies when defining configuration objects, routing tables, or any object where you want both validation and precise types. It's particularly useful for autocomplete in IDEs.
2. Discriminated Unions for State Management
Discriminated unions (tagged unions) are one of TypeScript's most powerful features for modeling states. By using a common discriminant property, TypeScript can narrow types automatically.
type AsyncData<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
function renderUser(state: AsyncData<User>) {
switch (state.status) {
case "idle":
return "Click to load";
case "loading":
return "Loading...";
case "success":
// TypeScript knows state.data exists here!
return state.data.name;
case "error":
// TypeScript knows state.error exists here!
return `Error: ${state.error.message}`;
}
}
Pro tip: Always use string literals (not enums) for discriminants. They're more lightweight and work better with type narrowing. This pattern is ideal for Redux reducers, API response states, and form validation.
3. const Assertions for Literal Types
Using as const tells TypeScript to infer the most specific type possible, making arrays readonly tuples and object properties readonly literals.
// Without const assertion
const colors1 = ["red", "green", "blue"];
// Type: string[]
// With const assertion
const colors2 = ["red", "green", "blue"] as const;
// Type: readonly ["red", "green", "blue"]
// Works great with objects too
const routes = {
home: "/",
about: "/about",
contact: "/contact"
} as const;
// routes.home is "/" (not string)
type Route = typeof routes[keyof typeof routes];
// Type: "/" | "/about" | "/contact"
Pro tip: Combine as const with keyof typeof to derive union types from object keys or values. This is excellent for configuration that shouldn't change at runtime and for creating type-safe enums without the enum keyword.
4. Template Literal Types for String Patterns
Template literal types let you create string types based on patterns, perfect for CSS properties, event names, or API endpoints.
type CSSUnit = "px" | "em" | "rem" | "%";
type Size = `${number}${CSSUnit}`;
const width: Size = "100px"; // Valid
const height: Size = "2rem"; // Valid
// const invalid: Size = "100"; // Error!
// Combine with other types
type HTTPMethod = "GET" | "POST" | "PUT" | "DELETE";
type Endpoint = `/api/${string}`;
type APIRoute = `${HTTPMethod} ${Endpoint}`;
const route: APIRoute = "GET /api/users"; // Valid
const create: APIRoute = "POST /api/users"; // Valid
// Generate event names automatically
type EventName<T extends string> = `on${Capitalize<T>}`;
type MouseEvent = EventName<"click" | "hover">;
// Type: "onClick" | "onHover"
Pro tip: Use template literal types with Uppercase, Lowercase, Capitalize, and Uncapitalize utility types to enforce naming conventions across your codebase. They're compile-time only, so there's zero runtime cost.
5. The infer Keyword for Extracting Types
The infer keyword lets you extract and capture types within conditional types. It's like pattern matching for types.
// Extract return type of a function
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
function getUser() {
return { id: 1, name: "Alice" };
}
type User = ReturnType<typeof getUser>;
// Type: { id: number; name: string; }
// Extract array element type
type ElementType<T> = T extends (infer E)[] ? E : never;
type StringArray = ElementType<string[]>; // string
type NumberArray = ElementType<number[]>; // number
// Extract Promise value type
type Awaited<T> = T extends Promise<infer U> ? U : T;
type Data = Awaited<Promise<string>>; // string
type Direct = Awaited<number>; // number
Pro tip: Use infer to build utility types that extract nested types from complex structures. It's particularly useful for unwrapping generic types, extracting function parameters, or working with deeply nested Promise types.
6. Exhaustive Switch with never
Force compile-time errors when you forget to handle a case in a discriminated union by using the never type as a sentinel.
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; size: number }
| { kind: "rectangle"; width: number; height: number };
function getArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.size ** 2;
case "rectangle":
return shape.width * shape.height;
default:
// This function ensures all cases are handled
const _exhaustive: never = shape;
throw new Error(`Unhandled shape: ${_exhaustive}`);
}
}
// If you add a new shape type, TypeScript will error at the default case!
Pro tip: Always add exhaustiveness checking in switch statements for discriminated unions. When you add new variants later, TypeScript will immediately show you everywhere you need to handle the new case. This prevents bugs in production.
7. Branded/Nominal Types for Type Safety
TypeScript uses structural typing, but sometimes you want nominal typing where types with the same structure are still incompatible. Branded types solve this with phantom properties.
// Create branded types
type Brand<T, TBrand extends string> = T & { __brand: TBrand };
type UserId = Brand<number, "UserId">;
type ProductId = Brand<number, "ProductId">;
// Brand constructor functions
function UserId(id: number): UserId {
return id as UserId;
}
function ProductId(id: number): ProductId {
return id as ProductId;
}
// Now these are incompatible!
function getUser(id: UserId) { /* ... */ }
function getProduct(id: ProductId) { /* ... */ }
const userId = UserId(123);
const productId = ProductId(456);
getUser(userId); // OK
// getUser(productId); // Error: Type 'ProductId' is not assignable to type 'UserId'
// getUser(789); // Error: Type 'number' is not assignable to type 'UserId'
Pro tip: Use branded types for IDs, URLs, email addresses, or any primitive that has semantic meaning. They prevent you from accidentally passing a user ID where a product ID is expected, catching bugs at compile time.
8. Utility Type Combinations
TypeScript's built-in utility types are powerful, but combining them unlocks even more possibilities for type transformations.
interface User {
id: number;
name: string;
email: string;
password: string;
createdAt: Date;
}
// Pick specific fields and make them readonly
type UserProfile = Readonly<Pick<User, "id" | "name" | "email">>;
// Omit fields and make everything optional
type UserUpdate = Partial<Omit<User, "id" | "createdAt">>;
// Make specific fields required in a Partial type
type UserRegistration = Required<Pick<Partial<User>, "name" | "email" | "password">>;
// Extract only string fields
type StringKeys<T> = {
[K in keyof T]: T[K] extends string ? K : never;
}[keyof T];
type UserStringFields = Pick<User, StringKeys<User>>;
// Type: { name: string; email: string; password: string; }
Pro tip: Chain utility types to create precise types for different use cases. Use Pick + Readonly for public APIs, Partial + Omit for update operations, and Required to enforce required fields in otherwise optional types.
9. Type Predicates with is
Type predicates let you write custom type guards that TypeScript understands, enabling precise type narrowing in your code.
interface Cat {
meow(): void;
}
interface Dog {
bark(): void;
}
type Pet = Cat | Dog;
// Type predicate function
function isCat(pet: Pet): pet is Cat {
return "meow" in pet;
}
function isDog(pet: Pet): pet is Dog {
return "bark" in pet;
}
function playWithPet(pet: Pet) {
if (isCat(pet)) {
// TypeScript knows pet is Cat here
pet.meow();
} else {
// TypeScript knows pet is Dog here
pet.bark();
}
}
// Works with arrays too
function filterCats(pets: Pet[]): Cat[] {
return pets.filter(isCat); // Type-safe!
}
Pro tip: Use type predicates for complex runtime checks that TypeScript can't infer automatically. They're essential when working with user input, API responses, or any data where the type isn't known statically. Combine them with filter, find, and other array methods for type-safe filtering.
10. Generic Constraints with extends
Generic constraints let you specify requirements for type parameters, ensuring they have certain properties or implement certain interfaces.
// Constrain to objects with an id property
function getId<T extends { id: number }>(obj: T): number {
return obj.id;
}
getId({ id: 1, name: "Alice" }); // OK
// getId({ name: "Bob" }); // Error: missing id
// Constrain to specific union
function parseValue<T extends string | number>(value: T): T {
// TypeScript knows value is string or number
return typeof value === "string" ? value.trim() as T : value;
}
// Multiple constraints with intersection
function merge<T extends object, U extends object>(a: T, b: U): T & U {
return { ...a, ...b };
}
// Constrain to keys of another type
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: 1, name: "Alice" };
const name = getProperty(user, "name"); // Type: string
// getProperty(user, "invalid"); // Error: "invalid" is not a key of user
Pro tip: Use keyof constraints to create type-safe property accessors. Use object constraints to ensure your generics work with structured data. Constraints make your generic functions more useful by providing autocomplete and preventing invalid usage.
11. Mapped Types for Transforming Interfaces
Mapped types let you create new types by transforming properties of existing types. They're the foundation of many utility types.
// Make all properties optional
type Optional<T> = {
[K in keyof T]?: T[K];
};
// Make all properties nullable
type Nullable<T> = {
[K in keyof T]: T[K] | null;
};
// Transform property types
type Promisify<T> = {
[K in keyof T]: Promise<T[K]>;
};
interface User {
id: number;
name: string;
}
type AsyncUser = Promisify<User>;
// Type: { id: Promise<number>; name: Promise<string>; }
// Add prefix to property names
type Prefixed<T, P extends string> = {
[K in keyof T as `${P}${Capitalize<string & K>}`]: T[K];
};
type PrefixedUser = Prefixed<User, "get">;
// Type: { getId: number; getName: string; }
// Filter properties by type
type StringProperties<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K];
};
type UserStrings = StringProperties<User>;
// Type: { name: string; }
Pro tip: Combine mapped types with template literal types and conditional types to build powerful type transformations. Use as clauses to rename keys. This is especially useful for generating API client types, form types, or validation schemas from existing interfaces.
12. Conditional Types for Flexible APIs
Conditional types select different types based on conditions, enabling highly flexible and reusable type definitions.
// Basic conditional type
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // false
// Distribute over unions
type ToArray<T> = T extends any ? T[] : never;
type StringOrNumberArray = ToArray<string | number>;
// Type: string[] | number[]
// Build flexible APIs
type ApiResponse<T, TError = Error> =
| { success: true; data: T }
| { success: false; error: TError };
async function fetchUser(): Promise<ApiResponse<User>> {
try {
const data = await fetch("/api/user").then(r => r.json());
return { success: true, data };
} catch (error) {
return { success: false, error: error as Error };
}
}
// Extract type based on a boolean flag
type FetchResult<T, TEager extends boolean> =
TEager extends true ? T : Promise<T>;
function fetch<T, TEager extends boolean = false>(
url: string,
eager?: TEager
): FetchResult<T, TEager> {
// Implementation...
return null as any;
}
const syncData = fetch<User, true>("/api/user", true); // Type: User
const asyncData = fetch<User>("/api/user"); // Type: Promise<User>
Pro tip: Use conditional types to create polymorphic APIs where the return type depends on input parameters. They're invaluable for overload-style APIs, optional lazy loading, or any scenario where one function should return different types based on how it's called.
13. Resource Management with using
TypeScript 5.2+ introduces the using keyword for explicit resource management, ensuring cleanup happens automatically even if errors occur.
// Define a disposable resource
interface Disposable {
[Symbol.dispose](): void;
}
class DatabaseConnection implements Disposable {
constructor(private connectionString: string) {
console.log("Connected to database");
}
query(sql: string) {
console.log(`Executing: ${sql}`);
return [];
}
[Symbol.dispose]() {
console.log("Connection closed");
}
}
// Use the resource - automatically disposed at end of scope
function queryDatabase() {
using connection = new DatabaseConnection("localhost:5432");
connection.query("SELECT * FROM users");
// Even if an error occurs here, connection will be disposed
if (Math.random() > 0.5) {
throw new Error("Query failed");
}
return connection.query("SELECT * FROM products");
// connection[Symbol.dispose]() called automatically here
}
// Works with async resources too
interface AsyncDisposable {
[Symbol.asyncDispose](): Promise<void>;
}
class FileHandle implements AsyncDisposable {
constructor(private path: string) {}
async read() {
return "file contents";
}
async [Symbol.asyncDispose]() {
console.log("File closed");
}
}
async function readFile() {
await using file = new FileHandle("data.txt");
const contents = await file.read();
return contents;
// file[Symbol.asyncDispose]() called automatically here
}
Pro tip: Use using for database connections, file handles, locks, or any resource that needs cleanup. It's safer than try/finally because it's automatic and works with multiple resources. This pattern is similar to Python's context managers or C#'s using statements.
14. Module Augmentation for Third-Party Types
Module augmentation lets you extend types from external libraries without modifying their source code. This is essential for adding custom properties or methods.
// Extend Express Request type with custom properties
import "express";
declare global {
namespace Express {
interface Request {
user?: {
id: number;
email: string;
};
requestId: string;
}
}
}
// Now you can use these properties with full type safety
import { Request, Response } from "express";
function authMiddleware(req: Request, res: Response) {
req.user = { id: 1, email: "user@example.com" };
req.requestId = crypto.randomUUID();
}
function protectedRoute(req: Request, res: Response) {
// TypeScript knows about req.user!
if (!req.user) {
return res.status(401).json({ error: "Unauthorized" });
}
res.json({ userId: req.user.id });
}
// Extend third-party module
declare module "lodash" {
interface LoDashStatic {
customMethod(value: string): string;
}
}
// Augment global objects
declare global {
interface Window {
analytics?: {
track(event: string, properties?: Record<string, any>): void;
};
}
}
// Now fully type-safe
window.analytics?.track("page_view", { path: "/home" });
Pro tip: Create a types directory in your project for module augmentations. Use this for Express middleware properties, augmenting test frameworks, or adding custom methods to libraries. Always prefer augmentation over forking libraries.
15. NoInfer<T> Utility Type
TypeScript 5.4+ introduces NoInfer<T> to prevent type inference in specific positions. This gives you fine-grained control over how TypeScript infers generic types.
// Without NoInfer - TypeScript infers from both arguments
function createCache<T>(initial: T, validate: (value: T) => boolean) {
return {
value: initial,
set(newValue: T) {
if (validate(newValue)) {
this.value = newValue;
}
}
};
}
// Problem: TypeScript widens type based on both arguments
const cache1 = createCache("hello", (v) => v.length > 0);
// T is inferred as string, but could be wider if arguments conflict
// With NoInfer - TypeScript only infers from first argument
function createBetterCache<T>(
initial: T,
validate: (value: NoInfer<T>) => boolean
) {
return {
value: initial,
set(newValue: T) {
if (validate(newValue)) {
this.value = newValue;
}
}
};
}
const cache2 = createBetterCache("hello", (v) => v.length > 0);
// T is inferred from "hello" only, validate must match
// Practical example: type-safe config merger
function mergeConfig<T>(
defaults: T,
overrides: Partial<NoInfer<T>>
): T {
return { ...defaults, ...overrides };
}
const config = mergeConfig(
{ port: 3000, host: "localhost", debug: false },
{ port: 8080 } // Must match the shape of defaults
);
// Advanced: prevent inference in specific positions
function createValidator<T>(
schema: T,
data: NoInfer<T>
): data is NoInfer<T> {
// Validation logic...
return true;
}
Pro tip: Use NoInfer when you want one parameter to drive type inference and other parameters to just conform to that type. This is particularly useful for configuration functions, validation functions, and any API where one argument should be the "source of truth" for the generic type.
Conclusion
These 15 TypeScript tips represent the evolution of the language from a simple type annotation system to a sophisticated type programming language. Each feature solves real problems that developers face when building large-scale applications.
The key to mastering TypeScript isn't memorizing every utility type or feature—it's understanding when and why to use them. Start with the basics like discriminated unions and const assertions, then gradually adopt more advanced patterns as your needs grow.
Remember that TypeScript's type system is Turing-complete, meaning you can express almost any type relationship you can imagine. But just because you can doesn't mean you should. Always prioritize readability and maintainability over clever type tricks.
The best TypeScript code is code where the types fade into the background, providing safety and autocomplete without getting in your way. Use these tips to build more robust applications, catch bugs at compile time, and make your code easier to understand and maintain.