Skip to main content

TypeScript Higher Order Types

Introduction

Higher Order Types in TypeScript are type-level constructs that operate on other types to produce new types. Much like higher-order functions in functional programming (functions that take functions as arguments or return functions), higher-order types take types as inputs and produce types as outputs.

Understanding higher-order types is crucial for advanced TypeScript development because they enable you to:

  • Create flexible and reusable type definitions
  • Manipulate and transform existing types
  • Build complex type relationships
  • Improve type safety in your applications

In this guide, we'll explore the concept of higher-order types, examine several examples, and demonstrate practical applications that you can use in your projects.

Prerequisites

Before diving into higher-order types, you should be familiar with:

  • Basic TypeScript syntax
  • Generics in TypeScript
  • Type aliases and interfaces
  • Union and intersection types

Understanding Higher Order Types

Higher-order types in TypeScript are primarily implemented through generics and utility types. They allow you to create type transformations that can be applied to other types.

The Basic Pattern

At its core, a higher-order type looks like this:

typescript
type HigherOrderType<T> = /* some operation on T */;

Here, T is a type parameter that represents any type. The higher-order type transforms T in some way to create a new type.

Common Higher Order Types

1. Mapped Types

Mapped types allow you to create new types by transforming the properties of an existing type.

typescript
type Optional<T> = {
[K in keyof T]?: T[K];
};

// Input
interface User {
id: number;
name: string;
email: string;
}

// Output
type OptionalUser = Optional<User>;
// Equivalent to:
// {
// id?: number;
// name?: string;
// email?: string;
// }

In this example, Optional<T> is a higher-order type that takes a type T and makes all of its properties optional.

2. Conditional Types

Conditional types let you create types that depend on conditions:

typescript
type ExtractNumberProperties<T> = {
[K in keyof T]: T[K] extends number ? K : never;
}[keyof T];

// Input
interface Product {
id: number;
name: string;
price: number;
description: string;
inStock: boolean;
}

// Output
type NumberProps = ExtractNumberProperties<Product>; // "id" | "price"

This higher-order type extracts all the property names from type T where the property type is a number.

3. Type Inference with Conditional Types

You can use the infer keyword within conditional types to extract types from other types:

typescript
type UnpackArray<T> = T extends Array<infer U> ? U : T;

// Input
type StringArray = string[];
type NumberArray = Array<number>;

// Output
type ExtractedString = UnpackArray<StringArray>; // string
type ExtractedNumber = UnpackArray<NumberArray>; // number
type NoChange = UnpackArray<boolean>; // boolean (not an array)

UnpackArray<T> is a higher-order type that extracts the element type from an array, or returns the original type if it's not an array.

Building Complex Higher Order Types

Let's combine these concepts to build more powerful higher-order types:

Deep Partial Type

A common use case is to create a type that makes all properties, including nested ones, optional:

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

// Input
interface Configuration {
server: {
port: number;
host: string;
ssl: {
enabled: boolean;
cert: string;
key: string;
}
};
database: {
url: string;
name: string;
}
}

// Output
type PartialConfig = DeepPartial<Configuration>;
// Now we can have incomplete configurations like:
const config: PartialConfig = {
server: {
port: 3000,
ssl: {
enabled: true
}
}
};

This recursive higher-order type handles nested objects and makes all properties optional at all levels.

Pick Properties by Value Type

Let's create a type that picks properties of a specific type:

typescript
type PickByValueType<T, ValueType> = {
[K in keyof T as T[K] extends ValueType ? K : never]: T[K]
};

// Input
interface Form {
name: string;
email: string;
age: number;
isSubscribed: boolean;
birthDate: Date;
contactCount: number;
}

// Output
type StringFields = PickByValueType<Form, string>;
// { name: string; email: string; }

type NumberFields = PickByValueType<Form, number>;
// { age: number; contactCount: number; }

This higher-order type extracts only the properties of a given value type.

Practical Applications

1. API Response Handling

Higher-order types can help manage API responses with proper typing:

typescript
type ApiResponse<T> = {
data: T;
status: number;
message: string;
timestamp: Date;
};

type ApiError = {
error: string;
status: number;
code: string;
};

// Discriminated union for success or error
type ApiResult<T> =
| { success: true; response: ApiResponse<T> }
| { success: false; error: ApiError };

// Usage
async function fetchUser(id: number): Promise<ApiResult<User>> {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
const error: ApiError = await response.json();
return { success: false, error };
}

const data: ApiResponse<User> = await response.json();
return { success: true, response: data };
} catch (err) {
return {
success: false,
error: {
error: err.message,
status: 500,
code: 'UNKNOWN_ERROR'
}
};
}
}

// Now using the result with type safety
const result = await fetchUser(123);
if (result.success) {
console.log(result.response.data.name); // Safe access
} else {
console.log(result.error.message); // Safe access
}

2. Form State Management

Higher-order types can be useful for form handling:

typescript
type FormField<T> = {
value: T;
touched: boolean;
error?: string;
validate: (value: T) => string | undefined;
};

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

// Input
interface UserForm {
username: string;
password: string;
age: number;
}

// Output
type UserFormState = FormState<UserForm>;
// Equivalent to:
// {
// username: FormField<string>;
// password: FormField<string>;
// age: FormField<number>;
// }

// Usage
const initialForm: UserFormState = {
username: {
value: '',
touched: false,
validate: (value) => value ? undefined : 'Username is required'
},
password: {
value: '',
touched: false,
validate: (value) => value.length >= 8 ? undefined : 'Password must be at least 8 characters'
},
age: {
value: 0,
touched: false,
validate: (value) => value >= 18 ? undefined : 'Must be 18 or older'
}
};

Advanced Composition of Higher Order Types

Let's combine multiple higher-order types to create more powerful compositions:

typescript
// Make some properties required and others optional
type RequireOnly<T, K extends keyof T> =
Required<Pick<T, K>> & Partial<Omit<T, K>>;

// Create readonly version of specific properties
type ReadonlyPart<T, K extends keyof T> =
Readonly<Pick<T, K>> & Omit<T, K>;

// Example usage
interface User {
id: number;
name: string;
email: string;
avatar?: string;
bio?: string;
}

// A user where only name and email are required
type NewUser = RequireOnly<User, 'name' | 'email'>;

// A user where id is readonly (can't be modified)
type ExistingUser = ReadonlyPart<User, 'id'>;

const newUser: NewUser = {
name: 'John Doe',
email: '[email protected]'
// id, avatar, and bio are optional
};

const existingUser: ExistingUser = {
id: 123,
name: 'Jane Smith',
email: '[email protected]'
};

// This would throw a type error:
// existingUser.id = 456; // Error: Cannot assign to 'id' because it is a read-only property

TypeScript Built-in Higher Order Types

TypeScript provides several built-in higher-order types (utility types) that you can use right away:

typescript
interface Example {
a: string;
b: number;
c: boolean;
}

// Partial<T> - Makes all properties optional
type PartialExample = Partial<Example>; // { a?: string; b?: number; c?: boolean; }

// Required<T> - Makes all properties required
type RequiredExample = Required<Example>; // { a: string; b: number; c: boolean; }

// Readonly<T> - Makes all properties readonly
type ReadonlyExample = Readonly<Example>; // { readonly a: string; readonly b: number; readonly c: boolean; }

// Pick<T, K> - Picks specific properties from T
type PickExample = Pick<Example, 'a' | 'c'>; // { a: string; c: boolean; }

// Omit<T, K> - Removes specific properties from T
type OmitExample = Omit<Example, 'b'>; // { a: string; c: boolean; }

// Record<K, T> - Creates a type with properties from K with values of type T
type RecordExample = Record<'x' | 'y', number>; // { x: number; y: number; }

// Extract<T, U> - Extracts types from T that are assignable to U
type ExtractExample = Extract<'a' | 'b' | 'c', 'a' | 'f'>; // 'a'

// Exclude<T, U> - Excludes types from T that are assignable to U
type ExcludeExample = Exclude<'a' | 'b' | 'c', 'a'>; // 'b' | 'c'

// NonNullable<T> - Removes null and undefined from T
type NonNullableExample = NonNullable<string | number | undefined | null>; // string | number

// ReturnType<T> - Gets the return type of a function type
type ReturnTypeExample = ReturnType<() => string>; // string

Creating a Type DSL (Domain Specific Language)

By composing higher-order types, you can create a domain-specific language for your application's type needs:

typescript
// API-specific higher-order types
namespace Api {
export type Resource<T> = {
data: T;
links: {
self: string;
related?: string[];
}
};

export type Collection<T> = {
items: T[];
total: number;
page: number;
pageSize: number;
links: {
self: string;
next?: string;
prev?: string;
}
};

export type Error = {
code: string;
message: string;
details?: unknown;
};

export type Result<T> =
| { kind: 'success'; data: T }
| { kind: 'error'; error: Error };

export type Paginated<T> = Result<Collection<T>>;
export type Single<T> = Result<Resource<T>>;
}

// Client code
interface User {
id: string;
name: string;
email: string;
}

// Typed API responses
async function getUsers(): Promise<Api.Paginated<User>> {
// Implementation
return {
kind: 'success',
data: {
items: [{ id: '1', name: 'John', email: '[email protected]' }],
total: 1,
page: 1,
pageSize: 10,
links: { self: '/api/users' }
}
};
}

async function getUser(id: string): Promise<Api.Single<User>> {
// Implementation
return {
kind: 'success',
data: {
data: { id: '1', name: 'John', email: '[email protected]' },
links: { self: `/api/users/${id}` }
}
};
}

Performance Considerations

While higher-order types are powerful, they can impact TypeScript's compilation performance. Here are some tips:

  1. Avoid deeply nested conditional types: They can slow down the type checker.
  2. Cache intermediate types: Define intermediate types rather than inlining complex type expressions.
  3. Be specific with constraints: Always add appropriate constraints to your generics.
  4. Consider splitting complex types: Break down very complex higher-order types into smaller pieces.

Summary

Higher-order types in TypeScript are powerful abstractions that allow you to:

  • Transform types in a flexible, reusable way
  • Create complex type relationships
  • Build domain-specific type systems
  • Enhance type safety without duplicating code

By mastering higher-order types, you'll write more maintainable and type-safe TypeScript code, reduce code duplication, and create powerful abstractions that can evolve with your application.

Exercises

  1. Create a higher-order type that makes all properties of an object nullable (can be the original type or null).
  2. Implement a DeepReadonly type that makes all properties (including nested ones) readonly.
  3. Create a FunctionProperties<T> type that extracts only the method properties from an object.
  4. Implement a PickByPrefixes<T, Prefix> type that selects properties from T where the property name starts with Prefix.
  5. Create a higher-order type to convert a union type to an intersection type.

Additional Resources

Understanding higher-order types is a journey, not a destination. The more you practice and experiment, the more powerful your TypeScript code will become!



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)