DEV Community

Midas126
Midas126

Posted on

Mastering TypeScript's Advanced Types: Beyond `string` and `number`

Mastering TypeScript's Advanced Types: Beyond string and number

TypeScript has taken the web development world by storm, and for good reason. It brings static typing to JavaScript, catching errors early and making our code more predictable. Most developers start with the basics: string, number, boolean, and maybe Array<string>. But TypeScript's true power lies in its advanced type system—a feature that can transform how you structure and reason about your code.

In this guide, we'll move beyond primitive types and explore practical applications of TypeScript's most powerful type features. You'll learn how to create self-documenting, resilient code that catches bugs at compile time rather than runtime.

Why Advanced Types Matter

Before we dive into the syntax, let's address the "why." Advanced types help you:

  • Encode business logic into your types (making invalid states unrepresentable)
  • Create self-documenting APIs that are clear to consumers
  • Reduce runtime type checking with compile-time guarantees
  • Build more maintainable codebases as requirements evolve

1. Union and Literal Types: Making Choices Explicit

Union types allow a value to be one of several types. When combined with literal types, they become incredibly powerful for modeling choices.

// Basic union type
type Status = 'loading' | 'success' | 'error';

// Function with explicit return possibilities
function fetchData(): Promise<Data> | Error {
  // Implementation
}

// More practical example: API response handler
type ApiResponse<T> = 
  | { status: 'success'; data: T }
  | { status: 'error'; message: string; code: number };

function handleResponse<T>(response: ApiResponse<T>) {
  switch (response.status) {
    case 'success':
      console.log('Data:', response.data); // TypeScript knows `data` exists here
      break;
    case 'error':
      console.error(response.message, response.code); // Knows `message` and `code` exist
      break;
  }
}
Enter fullscreen mode Exit fullscreen mode

The beauty here is TypeScript's type narrowing. Once we check response.status, TypeScript knows exactly which properties are available in each branch.

2. Template Literal Types: Dynamic String Patterns

Introduced in TypeScript 4.1, template literal types let you create types based on string patterns.

// Basic example
type EventName = 'click' | 'hover' | 'submit';
type HandlerName = `on${Capitalize<EventName>}`;
// Result: 'onClick' | 'onHover' | 'onSubmit'

// More practical: API endpoints
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type ApiRoute = '/users' | '/posts' | '/comments';

type ApiEndpoint = `${HttpMethod} ${ApiRoute}`;
// Result: 'GET /users' | 'POST /users' | 'PUT /users' | ...

// Real-world use case: Type-safe event emitters
type Events = {
  'user:created': { id: string; email: string };
  'order:updated': { orderId: string; status: string };
  'payment:failed': { userId: string; amount: number };
};

type EventHandler<T extends keyof Events> = (data: Events[T]) => void;

class EventEmitter {
  private handlers = new Map<string, Set<Function>>();

  on<T extends keyof Events>(event: T, handler: EventHandler<T>) {
    // Type-safe registration
    if (!this.handlers.has(event)) {
      this.handlers.set(event, new Set());
    }
    this.handlers.get(event)!.add(handler);
  }

  emit<T extends keyof Events>(event: T, data: Events[T]) {
    this.handlers.get(event)?.forEach(handler => handler(data));
  }
}

// Usage - completely type-safe!
const emitter = new EventEmitter();
emitter.on('user:created', (data) => {
  console.log(data.id, data.email); // TypeScript knows the shape
});

emitter.emit('user:created', { id: '123', email: 'test@example.com' }); // ✓
emitter.emit('user:created', { id: '123' }); // ✗ Error: missing email
Enter fullscreen mode Exit fullscreen mode

3. Conditional Types: Types That Adapt

Conditional types allow types to be selected based on conditions, similar to ternary operators but for types.

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

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

// Practical: Deep partial for API updates
type DeepPartial<T> = T extends object
  ? { [P in keyof T]?: DeepPartial<T[P]> }
  : T;

interface User {
  id: string;
  profile: {
    name: string;
    address: {
      street: string;
      city: string;
    };
  };
}

type PartialUser = DeepPartial<User>;
// Can update nested properties partially
const update: PartialUser = {
  profile: {
    address: {
      city: 'New York' // Only updating city, not street
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

4. Mapped Types with as Clauses (TS 4.1+)

TypeScript 4.1 introduced the ability to transform keys in mapped types using the as clause.

// Rename keys with prefix
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

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

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

// Filter keys based on value type
type StringProperties<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};

interface Product {
  id: string;
  name: string;
  price: number;
  description: string;
}

type ProductStrings = StringProperties<Product>;
// { id: string; name: string; description: string }
Enter fullscreen mode Exit fullscreen mode

5. Utility Types in Practice

While TypeScript provides built-in utility types, understanding how to create your own is crucial.

// Create your own utility: RequireAtLeastOne
type RequireAtLeastOne<T, Keys extends keyof T = keyof T> = 
  Keys extends keyof T 
    ? Required<Pick<T, Keys>> & Partial<Omit<T, Keys>>
    : never;

interface UserForm {
  email?: string;
  phone?: string;
  username?: string;
}

type UserFormWithContact = RequireAtLeastOne<UserForm, 'email' | 'phone'>;

// Valid:
const form1: UserFormWithContact = { email: 'test@example.com' };
const form2: UserFormWithContact = { phone: '123-456-7890' };
const form3: UserFormWithContact = { email: 'test@example.com', phone: '123-456-7890' };

// Invalid (will show TypeScript error):
const form4: UserFormWithContact = { username: 'test' }; // ✗ Missing email or phone
Enter fullscreen mode Exit fullscreen mode

Putting It All Together: A Type-Safe API Client

Let's build a practical example that combines several advanced types:

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';

type ApiConfig = {
  [Endpoint: string]: {
    [Method in HttpMethod]?: {
      request?: unknown;
      response: unknown;
    };
  };
};

// Define your API schema
type MyApi = {
  '/users': {
    GET: {
      response: User[];
    };
    POST: {
      request: { name: string; email: string };
      response: User;
    };
  };
  '/users/:id': {
    GET: {
      response: User;
    };
    PUT: {
      request: Partial<User>;
      response: User;
    };
  };
};

// Type-safe API client
class ApiClient {
  async request<
    Path extends keyof MyApi,
    Method extends keyof MyApi[Path]
  >(
    path: Path,
    method: Method,
    data?: MyApi[Path][Method] extends { request: infer Req }
      ? Req
      : never
  ): Promise<
    MyApi[Path][Method] extends { response: infer Res }
      ? Res
      : never
  > {
    const response = await fetch(path as string, {
      method: method as string,
      body: data ? JSON.stringify(data) : undefined,
      headers: { 'Content-Type': 'application/json' },
    });

    return response.json();
  }
}

// Usage - completely type-safe!
const api = new ApiClient();

// TypeScript knows the return type is User[]
const users = await api.request('/users', 'GET');

// TypeScript requires the correct request body
const newUser = await api.request('/users', 'POST', {
  name: 'John',
  email: 'john@example.com'
});

// TypeScript catches errors at compile time
const error = await api.request('/users', 'POST', {
  name: 'John' // ✗ Error: missing email
});
Enter fullscreen mode Exit fullscreen mode

Key Takeaways and Next Steps

Advanced TypeScript types aren't just academic exercises—they're practical tools that can significantly improve your code quality. Start by:

  1. Identifying common patterns in your codebase that could benefit from type safety
  2. Gradually introducing advanced types where they provide the most value
  3. Creating shared type utilities that your team can reuse
  4. Documenting complex types with comments explaining their purpose

Remember: the goal isn't to use every advanced feature, but to use the right features to make your code more robust and maintainable.

Your Challenge

This week, pick one area of your codebase and see how you can apply these advanced types. Maybe it's:

  • Making your Redux actions more type-safe with discriminated unions
  • Creating a type-safe form validation system
  • Building a type-safe router for your application

Share what you build in the comments below! What advanced TypeScript features have you found most useful in your projects?


Want to dive deeper? Check out the TypeScript Handbook and explore the type-challenges repository for hands-on practice with advanced type system features.

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.