Home / Articles / TypeScript Power Tools: extends, infer, keyof Maps, and as Remaps

This article is published in English.

TypeScript Power Tools: extends, infer, keyof Maps, and as Remaps

Build library-grade types by combining conditionals, inference, mapped loops, and key remapping—the same toolkit behind Zod and tRPC.

1192 words

After months with TypeScript, built-ins like Partial, Pick, and Omit eventually stop covering real library needs: extract a function’s return type, transform every key, or build types that adapt to other types. Four features combine to unlock that level: conditional extends, infer, mapped loops over keyof, and key remapping with as. Separately they are approachable; together they power patterns seen in Zod, tRPC, and React Router.

1. Conditional types with extends

In type position, extends is an if-statement:

type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>;      // false

Read: if T is assignable to string, resolve true, else false.

Conditionals become stronger when distributive. A union T is tested member-by-member:

type ToArray<T> = T extends any ? T[] : never;
type Result = ToArray<string | number>;
// Result = string[] | number[]   (not (string | number)[])

That is why filtering unions is short:

type ExcludeString<T> = T extends string ? never : T;
type NoStrings = ExcludeString<string | number | boolean>;
// NoStrings = number | boolean

never disappears from unions — the same idea behind built-in Exclude<T, U>.

2. Extracting shapes with infer

infer only appears inside an extends clause. It declares a placeholder TypeScript fills from the matched shape. Classic return-type extraction:

type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
function getUser() {
  return { id: 1, name: "Oussama" };
}type User = MyReturnType<typeof getUser>;
// User = { id: number; name: string }

Steps: match typeof getUser against (...args: any[]) => infer R, bind R to the real return, resolve to R.

infer works in other positions too:

// Extract the element type of an array
type ElementOf<T> = T extends (infer U)[] ? U : never;
type Item = ElementOf<string[]>; // string
// Extract the resolved type of a Promise
type Awaited2<T> = T extends Promise<infer U> ? U : T;
type Data = Awaited2<Promise<{ status: number }>>;
// Data = { status: number }// Extract the first argument of a function
type FirstArg<T> = T extends (arg: infer A, ...rest: any[]) => any ? A : never;
type Arg = FirstArg<(id: number, name: string) => void>; // number

Recursion unwraps nested promises:

type DeepAwaited<T> = T extends Promise<infer U> ? DeepAwaited<U> : T;
type Flat = DeepAwaited<Promise<Promise<Promise<string>>>>;
// Flat = string

3. Looping with keyof mapped types

keyof yields a union of keys:

interface User {
  id: number;
  name: string;
  email: string;
}
type UserKeys = keyof User; // "id" | "name" | "email"

Mapped types iterate that union like a type-level for...in:

type Readonly2<T> = {
  [K in keyof T]: T[K];
};

Add modifiers for optional or readonly copies:

// Make every property optional
type Optional<T> = {
  [K in keyof T]?: T[K];
};
// Make every property readonly
type ReadonlyAll<T> = {
  readonly [K in keyof T]: T[K];
};// Remove readonly / optional with a minus modifier
type Mutable<T> = {
  -readonly [K in keyof T]-?: T[K];
};

Transform values while looping:

type Stringify<T> = {
  [K in keyof T]: string;
};
type StringifiedUser = Stringify<User>;
// { id: string; name: string; email: string }

Combine with conditionals:

type NullableStrings<T> = {
  [K in keyof T]: T[K] extends string ? T[K] | null : T[K];
};
type Result2 = NullableStrings<User>;
// { id: number; name: string | null; email: string | null }

4. Key remapping with as

Inside a mapped type, as computes a new key name per property:

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type UserGetters = Getters<User>;
/*
{
  getId: () => number;
  getName: () => string;
  getEmail: () => string;
}
*/

[K in keyof T] iterates; the template literal builds getName-style keys; T[K] becomes the getter return type.

Use as never to drop keys while looping:

type OmitByType<T, U> = {
  [K in keyof T as T[K] extends U ? never : K]: T[K];
};
interface Mixed {
  id: number;
  name: string;
  active: boolean;
}type OnlyNonBoolean = OmitByType<Mixed, boolean>;
// { id: number; name: string }

That extends inside as pattern is how value-based Pick/Omit variants are built.

All four together

A realistic combo: validators for data fields, skipping function properties:

type Validators<T> = {
  [K in keyof T as T[K] extends Function ? never : `validate${Capitalize<string & K>}`]:
    (value: T[K]) => value is T[K];
};
interface ApiResponse {
  id: number;
  email: string;
  active: boolean;
  refresh: () => void;
}type ResponseValidators = Validators<ApiResponse>;
/*
{
  validateId: (value: number) => value is number;
  validateEmail: (value: string) => value is string;
  validateActive: (value: boolean) => value is boolean;
}
*/

Breakdown: keyof supplies keys; the mapped loop visits each; as renames or drops via never when T[K] extends Function; extends decides keep/drop; infer would appear if extracting nested argument types.

Quick reference

Feature Role Where
extends Type-level if Comparing types
infer Capture matched piece Only inside extends
[K in keyof T] Loop keys Mapped types
as Rename/drop keys Inside mapped as clause

Once these click, complicated shapes stop forcing any. Rebuild Pick, Omit, Record, and ReturnType from scratch — every feature above will show up — and the type-level toolkit becomes practical rather than mystical. Library authors rely on the same four switches; reading their .d.ts files gets much easier after a weekend of rebuilding the utilities by hand.

Practice path

Rebuild Exclude with distributive conditionals, ReturnType with infer, Partial/Readonly with mapped modifiers, and a custom OmitByType with as never. Those four exercises force every feature in this guide. After that, reading library .d.ts files stops feeling like code golf and starts feeling like ordinary TypeScript — just at the type layer.

When a type error explodes into a wall of conditionals, bisect: temporarily replace a mapped type with a concrete object type, or replace infer R with an explicit annotation, until the failing branch is obvious. Type-level debugging is still debugging; the same divide-and-conquer habit applies.

Prefer named aliases over giant one-liners so future readers can see intent. Export the helpers your app actually reuses; leave speculative utilities in a sandbox until a second call site appears. That keeps the type surface as intentional as the runtime API.