All posts
TypeScript

TypeScript Advanced Types: From Utility Types to Template Literals

12 min readby imnb
TypeScriptTypesGenericsUtility TypesType Safety
Share

Deep dive into TypeScript's type system with practical examples of utility types, conditional types, mapped types, and template literal types. Master type-level programming.

TypeScript's type system is incredibly powerful, going far beyond basic type annotations. In this guide, we'll explore advanced type features that enable type-safe, maintainable codebases at scale.

Utility Types: Your Type Toolkit

TypeScript provides built-in utility types that transform existing types. These are essential for real-world applications.

typescript
// Partial<T> - Makes all properties optional
interface User {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user';
}

// Update function accepts partial user data
function updateUser(id: number, updates: Partial<User>) {
  // Implementation
}

updateUser(1, { email: 'new@email.com' }); // ✅ Only email needed

// Required<T> - Makes all properties required
type UserWithDefaults = Partial<User>;
type CompleteUser = Required<UserWithDefaults>; // All fields required again

// Readonly<T> - Makes all properties immutable
const config: Readonly<User> = {
  id: 1,
  name: 'Admin',
  email: 'admin@app.com',
  role: 'admin'
};
// config.email = 'test'; // ❌ Error: Cannot assign to 'email'

// Pick<T, K> - Select specific properties
type UserPublicInfo = Pick<User, 'name' | 'email'>;
// { name: string; email: string; }

// Omit<T, K> - Remove specific properties
type UserWithoutId = Omit<User, 'id'>;
// { name: string; email: string; role: 'admin' | 'user'; }

// Record<K, T> - Create object type with specific keys
type UserRoles = Record<'admin' | 'user' | 'guest', string[]>;
const permissions: UserRoles = {
  admin: ['read', 'write', 'delete'],
  user: ['read', 'write'],
  guest: ['read']
};

Conditional Types: Type-Level if Statements

Conditional types enable types that change based on conditions. They unlock powerful type inference patterns.

typescript
// Basic conditional type syntax: T extends U ? X : Y
type IsString<T> = T extends string ? true : false;

type A = IsString<string>; // true
type B = IsString<number>; // false

// Real-world example: Type-safe API response handler
type ApiResponse<T> = T extends { error: any } 
  ? { success: false; error: string }
  : { success: true; data: T };

function handleResponse<T>(response: ApiResponse<T>) {
  if (response.success) {
    // TypeScript knows response.data exists here
    console.log(response.data);
  } else {
    // TypeScript knows response.error exists here
    console.error(response.error);
  }
}

// Exclude<T, U> - Remove types from union
type AllTypes = 'click' | 'focus' | 'blur' | 'submit';
type ClickTypes = Exclude<AllTypes, 'focus' | 'blur'>; // 'click' | 'submit'

// Extract<T, U> - Extract types from union
type EventNames = Extract<AllTypes, 'click' | 'submit'>; // 'click' | 'submit'

// NonNullable<T> - Remove null and undefined
type MaybeString = string | null | undefined;
type DefinitelyString = NonNullable<MaybeString>; // string

// Advanced: Recursive conditional types
type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends object 
    ? DeepReadonly<T[P]> 
    : T[P];
};

interface NestedConfig {
  database: {
    host: string;
    credentials: {
      username: string;
      password: string;
    };
  };
}

type ImmutableConfig = DeepReadonly<NestedConfig>;
// All nested properties are readonly

Mapped Types: Transform Existing Types

typescript
// Basic mapped type
type Nullable<T> = {
  [P in keyof T]: T[P] | null;
};

interface Post {
  id: number;
  title: string;
  body: string;
}

type NullablePost = Nullable<Post>;
// { id: number | null; title: string | null; body: string | null; }

// With modifiers: readonly and optional
type ReadonlyNullable<T> = {
  readonly [P in keyof T]?: T[P] | null;
};

// Mapping with conditional types
type Getters<T> = {
  [P in keyof T as `get${Capitalize<string & P>}`]: () => T[P];
};

interface Person {
  name: string;
  age: number;
}

type PersonGetters = Getters<Person>;
// {
//   getName: () => string;
//   getAge: () => number;
// }

// Real-world: Form validation types
type ValidationErrors<T> = {
  [P in keyof T]?: string[];
};

type FormState<T> = {
  values: T;
  errors: ValidationErrors<T>;
  touched: { [P in keyof T]?: boolean };
};

const userForm: FormState<User> = {
  values: { id: 0, name: '', email: '', role: 'user' },
  errors: { email: ['Invalid email format'] },
  touched: { email: true }
};

Template Literal Types

TypeScript 4.1+ allows creating types from string literals. Perfect for type-safe event systems and APIs.

typescript
// Basic template literal type
type EventName<T extends string> = `on${Capitalize<T>}`;
type ClickEvent = EventName<'click'>; // 'onClick'

// Combining with unions
type Direction = 'left' | 'right' | 'top' | 'bottom';
type PaddingDirection = `padding${Capitalize<Direction>}`;
// 'paddingLeft' | 'paddingRight' | 'paddingTop' | 'paddingBottom'

// Real-world: Type-safe event emitter
type Events = {
  'user:login': { userId: number; timestamp: Date };
  'user:logout': { userId: number };
  'post:create': { postId: number; authorId: number };
  'post:delete': { postId: number };
};

type EventKey = keyof Events;

class TypedEventEmitter {
  private listeners: {
    [K in EventKey]?: Array<(data: Events[K]) => void>;
  } = {};

  on<K extends EventKey>(
    event: K,
    callback: (data: Events[K]) => void
  ) {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    this.listeners[event]!.push(callback);
  }

  emit<K extends EventKey>(event: K, data: Events[K]) {
    this.listeners[event]?.forEach(cb => cb(data));
  }
}

const emitter = new TypedEventEmitter();

// ✅ Type-safe: correct payload type
emitter.on('user:login', (data) => {
  console.log(data.userId, data.timestamp);
});

// ❌ Error: Wrong payload type
// emitter.emit('user:login', { userId: 1 }); // Missing timestamp

// Advanced: API route typing
type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type APIRoute = `/api/${'users' | 'posts'}/${string}`;
type APIEndpoint = `${HTTPMethod} ${APIRoute}`;

type GetUserEndpoint = Extract<APIEndpoint, `GET /api/users/${string}`>;
// 'GET /api/users/${string}'

Type Inference with infer Keyword

typescript
// Extract return type from function
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

function getUser() {
  return { id: 1, name: 'John' };
}

type UserReturn = ReturnType<typeof getUser>;
// { id: number; name: string; }

// Extract promise resolved type
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;

type AsyncData = Promise<{ data: string[] }>;
type SyncData = UnwrapPromise<AsyncData>;
// { data: string[]; }

// Extract array element type
type ArrayElement<T> = T extends (infer U)[] ? U : never;

type Numbers = number[];
type NumberElement = ArrayElement<Numbers>; // number

// Real-world: Extract props from component
type ComponentProps<T> = T extends React.FC<infer P> ? P : never;

const Button: React.FC<{ label: string; onClick: () => void }> = () => null;
type ButtonProps = ComponentProps<typeof Button>;
// { label: string; onClick: () => void; }

Practical Patterns

typescript
// 1. Builder pattern with type safety
class QueryBuilder<T = {}> {
  private query: T = {} as T;

  where<K extends string, V>(
    key: K, 
    value: V
  ): QueryBuilder<T & Record<K, V>> {
    return new QueryBuilder<T & Record<K, V>>();
  }

  execute(): T {
    return this.query;
  }
}

const query = new QueryBuilder()
  .where('name', 'John')
  .where('age', 30)
  .execute();
// Type: { name: string; age: number; }

// 2. Discriminated unions for state management
type LoadingState = { status: 'loading' };
type SuccessState<T> = { status: 'success'; data: T };
type ErrorState = { status: 'error'; error: string };

type AsyncState<T> = LoadingState | SuccessState<T> | ErrorState;

function renderState<T>(state: AsyncState<T>) {
  switch (state.status) {
    case 'loading':
      return 'Loading...';
    case 'success':
      // TypeScript knows state.data exists
      return `Data: ${state.data}`;
    case 'error':
      // TypeScript knows state.error exists
      return `Error: ${state.error}`;
  }
}

// 3. Branded types for type safety
type Brand<K, T> = K & { __brand: T };
type USD = Brand<number, 'USD'>;
type EUR = Brand<number, 'EUR'>;

function processPayment(amount: USD) {
  console.log(`Processing $${amount}`);
}

const dollars = 100 as USD;
const euros = 100 as EUR;

processPayment(dollars); // ✅
// processPayment(euros); // ❌ Error: Type 'EUR' not assignable to 'USD'

Key Takeaways

  • Utility types (Partial, Pick, Omit, Record) solve 90% of common type transformations
  • Conditional types enable types that adapt based on input types
  • Mapped types transform existing types systematically
  • Template literal types provide type-safe string manipulation
  • The infer keyword extracts types from complex type structures
  • Discriminated unions with status fields enable exhaustive type checking
  • Branded types prevent mixing logically different but structurally identical types
  • Type-level programming makes impossible states unrepresentable
  • Good types catch bugs at compile time, not runtime

Keep reading