DEV Community

Cover image for Advanced TypeScript Techniques: Conditional Types, Mapped Types, and Type Inference Strategies for Better Code
Nithin Bharadwaj
Nithin Bharadwaj

Posted on

Advanced TypeScript Techniques: Conditional Types, Mapped Types, and Type Inference Strategies for Better Code

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

TypeScript's type system often feels like a superpower you can learn gradually. I remember when I first started, types were just simple annotations—strings, numbers, and basic interfaces. But over time, I discovered a whole other layer, a set of techniques that let me describe the shape and behavior of my code with incredible precision. These methods catch mistakes before the code runs and make my editor feel intelligent. Today, I want to walk you through some of these powerful methods, from the foundational to the more advanced.

Let's start with conditional types. Think of them as "if statements" for your types. They let a type decide what it is based on another type. This is useful for creating flexible utilities.

type IsNumber<T> = T extends number ? true : false;

type TestA = IsNumber<42>; // true
type TestB = IsNumber<'hello'>; // false
Enter fullscreen mode Exit fullscreen mode

You can use the infer keyword inside a conditional type to "capture" a piece of another type. A classic example is getting the type of value a function returns, even if you don't know the function yet.

type WhatDoesThisReturn<T> = T extends (...args: any) => infer ReturnType ? ReturnType : never;

function fetchData() { return { id: 1, title: 'Item' }; }

type FetchedData = WhatDoesThisReturn<typeof fetchData>;
// FetchedData is now { id: number; title: string; }
Enter fullscreen mode Exit fullscreen mode

Conditional types distribute over unions. This means if you give it string | number, it will apply the logic to string and then to number, and give you back the combined result.

type ToArray<T> = T extends any ? T[] : never;
type Result = ToArray<string | number>; // string[] | number[]
Enter fullscreen mode Exit fullscreen mode

Sometimes you don't want that distribution. You can prevent it by wrapping the check in a tuple.

type ToArrayNoDistribute<T> = [T] extends [any] ? T[] : never;
type Result2 = ToArrayNoDistribute<string | number>; // (string | number)[]
Enter fullscreen mode Exit fullscreen mode

The next concept is mapped types. These allow you to create a new type by transforming each property of an existing type. It's like using map() on an array, but for object keys.

The simplest mapped type might make every property optional or read-only.

type MakeAllOptional<T> = {
  [Key in keyof T]?: T[Key];
};

type MakeAllReadonly<T> = {
  readonly [Key in keyof T]: T[Key];
};

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

type PartialUser = MakeAllOptional<User>;
// { id?: number; name?: string; }

type ReadonlyUser = MakeAllReadonly<User>;
// { readonly id: number; readonly name: string; }
Enter fullscreen mode Exit fullscreen mode

You can also add or remove modifiers like ? (optional) or readonly using a + or - sign.

type MakeRequired<T> = {
  [Key in keyof T]-?: T[Key];
};

type UserWithOptionalId = { id?: number; name: string };
type RequiredUser = MakeRequired<UserWithOptionalId>; // { id: number; name: string }
Enter fullscreen mode Exit fullscreen mode

The real power comes when you combine this with the as clause for key remapping. You can filter properties or rename them based on patterns.

// Only get properties that are functions
type FunctionProperties<T> = {
  [Key in keyof T as T[Key] extends Function ? Key : never]: T[Key];
};

// Add a 'get' prefix to every key
type Getters<T> = {
  [Key in keyof T as `get${Capitalize<string & Key>}`]: () => T[Key];
};

interface DataModel {
  value: number;
  calculate(): number;
}

type JustFunctions = FunctionProperties<DataModel>; // { calculate: () => number; }
type DataGetters = Getters<DataModel>;
// { getValue: () => number; getCalculate: () => () => number; }
Enter fullscreen mode Exit fullscreen mode

This leads us directly into template literal types. They let you manipulate string literal types using template string syntax. This is fantastic for creating type-safe routes, CSS class names, or method names.

type Color = 'red' | 'blue';
type Size = 'sm' | 'lg';

type ButtonClass = `btn-${Size}-${Color}`;
// "btn-sm-red" | "btn-sm-blue" | "btn-lg-red" | "btn-lg-blue"

const myClass: ButtonClass = 'btn-lg-red'; // Works
// const badClass: ButtonClass = 'btn-md-green'; // Error
Enter fullscreen mode Exit fullscreen mode

You can also parse these strings back apart using infer within a template literal.

type ExtractParts<T> = T extends `btn-${infer S}-${infer C}` ? [S, C] : never;
type Parts = ExtractParts<'btn-sm-red'>; // ["sm", "red"]
Enter fullscreen mode Exit fullscreen mode

For working with existing values, TypeScript provides powerful inference operators. typeof grabs the static type of a JavaScript value.

const config = { host: 'localhost', port: 3000 };
type Config = typeof config;
// { host: string; port: number; }
Enter fullscreen mode Exit fullscreen mode

The keyof operator gives you a union of an object's keys as string literal types.

interface Product {
  id: number;
  name: string;
  price: number;
}
type ProductKey = keyof Product; // "id" | "name" | "price"
Enter fullscreen mode Exit fullscreen mode

You can combine keyof with indexed access types, which use square brackets to look up the type of a specific property.

type ProductNameType = Product['name']; // string
type ProductPriceType = Product['price']; // number

// You can even look up with a union of keys
type IdOrName = Product['id' | 'name']; // number | string
Enter fullscreen mode Exit fullscreen mode

Indexed access works deeply, which is incredibly useful for extracting types from nested structures without re-declaring them.

interface APIResponse {
  data: {
    user: {
      id: string;
      profile: { name: string };
    };
  };
}
type UserProfileName = APIResponse['data']['user']['profile']['name']; // string
Enter fullscreen mode Exit fullscreen mode

Utility types are pre-built tools that combine these concepts. Pick and Omit are everyday essentials.

interface FullUser {
  id: number;
  name: string;
  email: string;
  passwordHash: string;
  createdAt: Date;
}

// Create a type for public-facing data
type PublicUser = Pick<FullUser, 'id' | 'name' | 'email'>;
// { id: number; name: string; email: string; }

// Or create one by removing sensitive data
type SafeUser = Omit<FullUser, 'passwordHash'>;
// { id: number; name: string; email: string; createdAt: Date; }
Enter fullscreen mode Exit fullscreen mode

Pick and Omit are actually built using mapped and conditional types. Understanding this helps you build your own utilities.

// A simplified version of how 'Pick' might work
type MyPick<T, K extends keyof T> = {
  [P in K]: T[P];
};

type PickedName = MyPick<FullUser, 'name' | 'email'>;
// { name: string; email: string; }
Enter fullscreen mode Exit fullscreen mode

Sometimes you need to constrain a generic type parameter. This means telling TypeScript, "This type can be anything, as long as it has at least this shape."

// A function that only works with objects that have an 'id' property
function getById<T extends { id: string }>(items: T[], id: string): T | undefined {
  return items.find(item => item.id === id);
}

const users = [{ id: 'a1', name: 'Alice' }, { id: 'b2', name: 'Bob' }];
const found = getById(users, 'b2'); // Type is { id: string; name: string; }
Enter fullscreen mode Exit fullscreen mode

You can also constrain a type parameter based on another type parameter. This is common in function chains or builders.

// A 'merge' function where the return type is the combination of both inputs
function merge<A extends object, B extends object>(a: A, b: B): A & B {
  return { ...a, ...b };
}

const result = merge({ foo: 1 }, { bar: 'two' });
// result has type { foo: number; } & { bar: string; }
Enter fullscreen mode Exit fullscreen mode

Finally, let's talk about recursive types for handling nested structures. You can define a type that references itself. This is perfect for things like tree nodes or nested comments.

interface TreeNode<T> {
  value: T;
  children?: TreeNode<T>[];
}

const tree: TreeNode<string> = {
  value: 'root',
  children: [
    { value: 'child1' },
    { value: 'child2', children: [{ value: 'grandchild' }] }
  ]
};
Enter fullscreen mode Exit fullscreen mode

You can even create recursive type aliases for operations. Here's a type that makes every level of an object deeply optional.

type DeepPartial<T> = T extends object ? {
  [P in keyof T]?: DeepPartial<T[P]>;
} : T;

interface Settings {
  user: {
    theme: string;
    dashboard: { widgets: string[] };
  };
}

type PartialSettings = DeepPartial<Settings>;
// Now user, theme, dashboard, and widgets can all be optional at any depth.
Enter fullscreen mode Exit fullscreen mode

Putting it all together, the goal is to make the type system work for you. It's about describing your intentions so clearly that the compiler can catch inconsistencies. It's not just about preventing errors; it's about creating a living document of your code's contracts.

Start by using typeof and keyof to avoid repetition. Then, try building a small utility type with a conditional or mapped type to solve a specific problem, like extracting all the string properties from an interface. Gradually, these techniques become part of your regular workflow. You'll find yourself modeling your domain not just in your runtime code, but in the type space as well, leading to software that is more robust and easier to change. The compiler becomes a true partner in the development process.

📘 Checkout my latest ebook for free on my channel!

Be sure to like, share, comment, and subscribe to the channel!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Top comments (0)