DEV Community

Tarun Moorjani
Tarun Moorjani

Posted on

The TypeScript Features I Wish I'd Learned Sooner

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

"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!
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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; }
Enter fullscreen mode Exit fullscreen mode

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; }>!
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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!
};
Enter fullscreen mode Exit fullscreen mode

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.
};
Enter fullscreen mode Exit fullscreen mode

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() { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

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) {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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<...>
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

What I'd Tell My Past Self

  1. Learn satisfies first. It solves the most common pain point: wanting both validation and precise types.

  2. Use as const liberally. Any time you have data that should become a type, slap as const on it.

  3. Don't fear advanced types. Start with ReturnType and typeof, then explore from there. The pattern repeats.

  4. Namespaces aren't dead. They're perfect for organizing related types and functions.

  5. Read the tsconfig docs. Seriously. Five minutes of reading could prevent hours of debugging.

  6. 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");
Enter fullscreen mode Exit fullscreen mode

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)