Dev.to WebDev 🛠 Dev 👁 1 📖 6 min read

TypeScript Tricks That Feel Like Cheating

TypeScript looks simple when you're writing basic types and interfaces.But once you start exploring its advanced features, you realize it can do much more than just check whether a variable is a string or a number. Some

TypeScript looks simple when you're writing basic types and interfaces.But once you start exploring its advanced features, you realize it can do much more than just check whether a variable is a string or a number.

Some TypeScript features can help you catch mistakes at compile time, build smarter APIs, and even calculate types based on other types.

Here are 8 TypeScript tricks that made me appreciate its type system a lot more.

1. Create Types From Existing Objects Using typeof

What if you could create a TypeScript type directly from a JavaScript object instead of maintaining both separately?

You can.

const config = {
  environment: "production",
  retries: 3,
  debug: false,
};

type Config = typeof config;

Now Config represents the structure inferred from config.

But there is an important detail: environment is inferred as string, not specifically "production".

If you want to preserve literal values, use as const:

const config = {
  environment: "production",
  retries: 3,
  debug: false,
} as const;

type Config = typeof config;

Now the properties have literal types, and they become readonly in the inferred type.

Why this is useful: Configuration objects, shared constants, and avoiding duplicate type definitions.

2. Turn an Array Into a Union Type

Imagine you have a list of supported payment methods.

You want the list to be the source of truth, rather than maintaining a separate union type.

const paymentMethods = [
  "card",
  "paypal",
  "bank-transfer",
] as const;

type PaymentMethod =
  (typeof paymentMethods)[number];

The resulting type is:

type PaymentMethod =
  "card" | "paypal" | "bank-transfer";

Let's break it down:

  • typeof paymentMethods gets the array's type.
  • [number] extracts the type of its elements.
  • as const preserves each string as a literal type.

Now this works:

const method: PaymentMethod = "card";

But this fails:

const method: PaymentMethod = "crypto";
// TypeScript error

Why this is useful: Roles, permissions, status values, supported features, and configuration options.

3. Use satisfies Without Losing Type Inference

This is one of my favorite TypeScript features.

Sometimes you want to verify that an object follows a specific structure without replacing its inferred type with that structure.

That's what satisfies helps with.

type Route = {
  path: string;
  requiresAuth: boolean;
};

const routes = {
  home: {
    path: "/",
    requiresAuth: false,
  },
  dashboard: {
    path: "/dashboard",
    requiresAuth: true,
  },
} satisfies Record<string, Route>;

TypeScript checks that every route matches the required structure.

But unlike a straightforward type annotation, the satisfies operator preserves useful details from the expression's inferred type.

For example:

routes.dashboard.requiresAuth;
// Inferred as boolean

The expression retains its property information, while the satisfies check ensures that the object conforms to Record<string, Route>.

Why this is useful: Route configurations, feature flags, application settings, and event maps.

4. Make Impossible States Impossible

Imagine an API request can be in one of three states: loading, successful, or failed.

A poorly designed type might allow contradictory combinations, such as a failed request that also contains successful data.

A discriminated union can model the valid states more clearly.

type ApiState<T> =
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; message: string };

Now you can write:

function handleState<T>(state: ApiState<T>) {
  switch (state.status) {
    case "loading":
      return "Please wait";

    case "success":
      return state.data;

    case "error":
      return state.message;
  }
}

TypeScript knows which properties are available in each branch.

You cannot accidentally access data on the loading state without checking which state you're dealing with.

Why this is useful: API responses, authentication flows, payment processing, and background jobs.

5. Use never to Catch Missing Cases

Suppose you handle every order status in a switch statement.

Later, someone adds another status, but forgets to update your function.

You can make TypeScript catch that mistake.

type OrderStatus =
  | "pending"
  | "shipped"
  | "delivered";

function assertNever(value: never): never {
  throw new Error(`Unexpected value: ${value}`);
}

function getMessage(status: OrderStatus): string {
  switch (status) {
    case "pending":
      return "Order is pending";

    case "shipped":
      return "Order has shipped";

    case "delivered":
      return "Order delivered";

    default:
      return assertNever(status);
  }
}

Now add "cancelled" to OrderStatus.

TypeScript reports an error at assertNever(status) because status can now be "cancelled" instead of being narrowed to never.

You are forced to handle the new case.

Why this is useful: Business workflows, payment states, event handlers, and large applications where missing a case can cause bugs.

6. Build Types From Strings Using Template Literal Types

TypeScript can generate new string types from existing ones.

For example, imagine you have a list of application events.

type EventName = "user" | "order" | "payment";

type EventHandler = `on${Capitalize<EventName>}`;

The resulting type becomes:

type EventHandler =
  | "onUser"
  | "onOrder"
  | "onPayment";

You can even create event names dynamically at the type level:

type Action = "create" | "delete";
type Entity = "user" | "order";

type Permission = `${Action}:${Entity}`;

This generates:

type Permission =
  | "create:user"
  | "create:order"
  | "delete:user"
  | "delete:order";

Why this is useful: Permission systems, event naming conventions, typed API endpoints, and reusable libraries.

7. Use keyof to Build Safer Generic Functions

Suppose you want a function that retrieves a property from an object.

A naive implementation might accept any string as the property name, which can lead to mistakes.

Here's a safer version:

function getProperty<T, K extends keyof T>(
  object: T,
  key: K
): T[K] {
  return object[key];
}

const user = {
  id: 101,
  name: "Alex",
  active: true,
};

const name = getProperty(user, "name");
const active = getProperty(user, "active");

TypeScript infers name as string and active as boolean.

But this fails:

getProperty(user, "email");
// TypeScript error

Why?

  • keyof T produces the valid keys of an object type.
  • K extends keyof T restricts the key argument.
  • T[K] represents the type of the selected property.

Why this is useful: Reusable utility functions, form libraries, state management, and object mapping.

8. Use Recursive Conditional Types to Transform Types

This one is more advanced.

TypeScript can define types that refer back to themselves, allowing recursive transformations.

For example, you can create a type that makes every nested object property readonly.

type DeepReadonly<T> =
  T extends (...args: any[]) => any
    ? T
    : T extends readonly unknown[]
      ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
      : T extends object
        ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
        : T;

Now consider:

type Settings = {
  app: {
    theme: string;
    notifications: {
      email: boolean;
    };
  };
};

type ReadonlySettings = DeepReadonly<Settings>;

Properties such as app.theme and app.notifications.email become readonly at the type level.

This means TypeScript will reject assignments to those properties.

A couple of important details:

  • This is a compile-time transformation, not runtime freezing.
  • Recursive types can become expensive or difficult for the compiler to evaluate when used with very complex structures.

Why this is useful: Configuration models, immutable state, and advanced type utilities.

Bonus: TypeScript Types Don't Validate Runtime Data

This is an important distinction that even experienced developers sometimes overlook.

interface User {
  id: number;
  name: string;
}

const user = JSON.parse('{"id":"wrong","name":123}') as User;

TypeScript accepts the assertion, but the actual object still contains a string for id and a number for name.

Why? Because as User tells the compiler to treat the value as that type. It doesn't validate the object.

When dealing with external data, use runtime validation with a schema or explicit checks.

Types make development safer, but they don't replace runtime validation.

Final Thoughts

TypeScript becomes much more interesting when you move beyond basic types and interfaces.

Features like satisfies, keyof, template literal types, discriminated unions, and recursive conditional types allow you to catch mistakes and express complex rules directly in your code.

My suggestion: don't use advanced types just because you can. Use them when they make your code safer, clearer, and easier to maintain.

Which TypeScript trick do you use most in your projects? Did I miss one worth sharing?

Let's discuss in the comments.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.