Skip to main content

Advanced Types

Advanced Types in TypeScript include several powerful features for creating more complex and flexible type definitions. Here are some of the key members of this category.


Intersection Types (A & B)

These allow you to combine multiple types into one. This is useful when you want an entity to adhere to multiple type contracts.


Union Types (A | B)

With union types, you can define a type that can be one of several types. This is incredibly useful for functions that might accept different types of parameters or for variables that might hold different types of values.


Type Guards and Differentiating Types

TypeScript allows you to use conditional checks to ensure the type of a variable within a scope. This includes using type predicates, typeof guards, and instanceof guards.

typeof

function padLeft(value: string, padding: string | number): string {
if (typeof padding === "number") {
return Array(padding + 1).join(" ") + value;
}
if (typeof padding === "string") {
return padding + value;
}
throw new Error(`Expected string or number, got '${typeof padding}'.`);
}

console.log(padLeft("Hello, world", 4)); // " Hello, world"
console.log(padLeft("Hello, world", ">>>")); // ">>>Hello, world"

instanceof

class Bird {
fly() {
console.log("Flying");
}
}

class Fish {
swim() {
console.log("Swimming");
}
}

function move(pet: Bird | Fish) {
if (pet instanceof Bird) {
pet.fly();
} else if (pet instanceof Fish) {
pet.swim();
}
}

const myBird = new Bird();
const myFish = new Fish();

move(myBird); // "Flying"
move(myFish); // "Swimming"

Custom Type Guards

Custom type guards allow you to define a function that acts as a runtime type check and informs TypeScript about the type within a scope based on a return statement.

interface Bird {
fly(): void;
layEggs(): void;
}

interface Fish {
swim(): void;
layEggs(): void;
}

function isFish(pet: Bird | Fish): pet is Fish {
return (pet as Fish).swim !== undefined;
}

function getSmallPet(): Bird | Fish {
// Dummy function to return a pet
return { swim: () => console.log("swimming"), layEggs: () => console.log("laying eggs") };
}

let pet = getSmallPet();

if (isFish(pet)) {
pet.swim();
} else {
pet.fly();
}

Mapped Types

These let you create new types by transforming all properties of an existing type according to certain rules. They're like a form of type-level programming, allowing for very flexible and DRY (Don't Repeat Yourself) type definitions.

Mapped types in TypeScript allow you to take an existing model and transform each of its properties into new types. They're incredibly powerful for creating new types based on old ones, with operations like making all properties optional, readonly, or even transforming the types of the properties themselves.

Making All Properties Optional

Suppose you have an interface User and you want to create a type where all properties are optional for use in a function that partially updates a user.

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

type PartialUser = {
[P in keyof User]?: User[P];
};

Making All Properties Readonly

If you want to make an immutable version of an object, you can use mapped types to make all properties readonly.

type ReadonlyUser = {
readonly [P in keyof User]: User[P];
};

Mapping to Another Type

You can transform each property to another type entirely. For example, converting each property to a boolean indicating if the property exists.

type PropertyFlags = {
[P in keyof User]: boolean;
};

Conditional Types within Mapped Types

You can also use conditional types within mapped types to selectively apply transformations.

type NullableUser = {
[P in keyof User]: User[P] | null;
};

type Stringify<T> = {
[P in keyof T]: string;
};

type StringifiedUser = Stringify<User>;

Using Partial, Readonly, Pick, and Record Utility Types

TypeScript provides built-in utility types that make it easier to create common mapped types. Here's how you could use them:

  • Partial<Type> makes all properties of Type optional.
  • Readonly<Type> makes all properties of Type readonly.
  • Pick<Type, Keys> creates a type by picking the set of properties Keys from Type.
  • Record<Keys, Type> creates a type with a set of properties Keys of a given type.
// Making User properties optional using Partial
type PartialUserBuiltIn = Partial<User>;

// Making User properties readonly using Readonly
type ReadonlyUserBuiltIn = Readonly<User>;

// Picking a subset of properties from User
type UserNameAndAge = Pick<User, 'name' | 'age'>;

// Creating a record with string keys and number values
type UsersById = Record<string, User>;

Mapped types are a flexible and powerful feature in TypeScript, enabling developers to create types that are derived from existing ones, with modifications as needed.


Conditional Types (T extends U ? X : Y)

These types enable you to define a type relationship that depends on a condition. They're akin to ternary operations but at the type level, allowing for types that change based on whether a condition is true or false.

Conditional types in TypeScript allow you to choose types based on conditions. They follow the form T extends U ? X : Y, meaning if T can be assigned to U, the type is X, otherwise, it's Y. They are incredibly powerful for creating type-safe utilities and handling cases where the type of a variable depends on conditions.

Basic Conditional Type

A basic example that checks if a type is assignable to another and chooses a type accordingly:

type IsNumber<T> = T extends number ? 'number' : 'not a number';

type Result1 = IsNumber<42>; // 'number'
type Result2 = IsNumber<'hello'>; // 'not a number'

Conditional Types with Generics

Using conditional types in generic functions or interfaces can make them more flexible:

type NonNullable<T> = T extends null | undefined ? never : T;

function safeGet<T>(value: T): NonNullable<T> {
if (value == null) {
throw new Error('Null or undefined');
}
return value as NonNullable<T>;
}

const number: number = safeGet<number | null>(null); // Throws error
const string: string = safeGet<string | undefined>('Hello'); // Returns 'Hello'

Distributive Conditional Types

TypeScript applies conditional types over unions in a distributive manner, meaning it applies the condition to each member of the union separately:

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

type Test1 = Flatten<string[]>; // string
type Test2 = Flatten<number>; // number

Conditional Type Constraints

You can also use conditional types to enforce constraints on generic types:

type SmallerThan100<T extends number> = T extends 100 ? never : T;

// The following line will cause an error because 100 is not assignable to type 'never'.
// type InvalidType = SmallerThan100<100>;

// Correct usage
type ValidType = SmallerThan100<99>; // 99

Inferring Within Conditional Types

Using infer within conditional types can help extract types from other types, useful for type manipulation and utility types:

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

function getString() {
return "Hello, TypeScript";
}

function getNumber() {
return 42;
}

type StringReturnType = ReturnType<typeof getString>; // string
type NumberReturnType = ReturnType<typeof getNumber>; // number

Advanced Usage: Wrapping/Unwrapping Promises

Conditional types can unwrap types wrapped in a higher-order type like Promise:

type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;

async function getValue() {
return "Async value";
}

type ResolvedValue = UnwrapPromise<ReturnType<typeof getValue>>; // string

Template Literal Types

These are types that use template literal syntax to define types that are based on string patterns. This can be incredibly useful for defining types with specific string format requirements.

Practical Applications

Template Literal Types are particularly useful for type-safe APIs, routing libraries, or any scenario where you need to enforce specific string patterns at compile time. For example, defining API endpoints, CSS-related types (e.g., padding, margin with specific units), or handling event names in a type-safe manner.

Template Literal Types in TypeScript, introduced in TypeScript 4.1, allow you to use string literal types in a more dynamic and powerful way. They enable the creation of complex string types by combining string literals with type operations. This feature is particularly useful for creating types that match specific patterns or formats, such as URLs, CSS units, or property paths.

Basic Usage

You can concatenate literal strings with other types to form new string literals:

type World = "world";
type Greeting = `hello ${World}`; // "hello world"

Dynamic String Patterns

Template Literal Types can be combined with unions to generate a type that represents several possible strings:

type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
type Endpoint = `/api/${HttpMethod}`;

// The type of Endpoint is "/api/GET" | "/api/POST" | "/api/PUT" | "/api/DELETE"

Using with Union Types

When combined with union types, template literal types become even more powerful, allowing for the creation of a wide range of string patterns:

type XY = "X" | "Y";
type Direction = `${XY}Axis`; // "XAxis" | "YAxis"

Advanced Patterns

Template literal types can also be used for more advanced patterns, such as enforcing specific formats:

type UserID = `user_${string}`;
type OrderID = `order_${string}`;

// These types ensure that IDs follow a specific pattern, e.g., user_xyz, order_123, etc.

Inference with Template Literals

You can use template literal types with type inference to extract parts of strings:

type ExtractController<Action extends `on${string}`> = Action extends `on${Capitalize<infer Controller>}` ? Controller : never;

type Controller = ExtractController<"onClick">; // "Click"

Conditional Types with Template Literals

Template literal types can be used in conjunction with conditional types to create more flexible type manipulations:

type Status = "loading" | "success" | "error";
type StatusMessage<StatusType extends Status> = `${StatusType}_message`;

type LoadingMessage = StatusMessage<"loading">; // "loading_message"
type ErrorMessage = StatusMessage<"error">; // "error_message"

Indexed Access Types (T[K]) and Lookup Types

These allow you to access the type of a property or an element of an array within another type, enabling you to work with types that are nested or part of complex data structures.

Indexed Access Types and Lookup Types in TypeScript are powerful features that allow you to access the type of a property within another type. This is particularly useful when you need to create more specific or dynamic type definitions based on the properties of objects.

Indexed Access Types (T[K])

Indexed Access Types use the syntax T[K], where T is a type and K is a key (or set of keys) in type T. This allows you to access the type of a specific property within T.

type Person = {
name: string;
age: number;
hasPet: boolean;
};

// Accessing the type of 'name' property in Person
type NameType = Person['name']; // string

// Accessing the type of 'age' property in Person
type AgeType = Person['age']; // number

// Accessing the type of 'hasPet' property in Person
type HasPetType = Person['hasPet']; // boolean

Lookup Types

Lookup Types are essentially the same concept as Indexed Access Types, often used interchangeably to describe accessing property types by their keys.

Example with Dynamic Keys

You can use a type or a union of types as keys to dynamically access types within another type.

type Pet = {
name: string;
type: 'dog' | 'cat' | 'bird';
age: number;
};

// Using a union type to access multiple properties at once
type PetInfo = Pet['name' | 'type']; // string | 'dog' | 'cat' | 'bird'

// Dynamically accessing a type based on a generic key
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}

const myPet: Pet = {
name: 'Fido',
type: 'dog',
age: 5,
};

const petName: string = getProperty(myPet, 'name');
const petType: 'dog' | 'cat' | 'bird' = getProperty(myPet, 'type');

In this example, getProperty is a generic function that can dynamically return the type of a property from a given object, demonstrating how powerful Indexed Access Types can be when combined with generics.

Using Indexed Access Types for Type Safety

Indexed Access Types can also enhance type safety by ensuring the correct types are used in contexts like function parameters, return types, and more.

type User = {
id: number;
username: string;
isAdmin: boolean;
};

// Ensuring function parameters are correctly typed
function setUserProperty<K extends keyof User>(
user: User,
propertyName: K,
value: User[K]
): User {
return { ...user, [propertyName]: value };
}

const user: User = {
id: 1,
username: 'user1',
isAdmin: false,
};

// Correct usage
const updatedUser = setUserProperty(user, 'isAdmin', true);

// Incorrect usage - TypeScript will throw an error
// const wrongUpdate = setUserProperty(user, 'isAdmin', 'yes');
keyof

When using Indexed Access Types and Lookup Types, leverage TypeScript's keyof type operator to ensure type safety and flexibility. This operator allows you to use the keys of a type as a union type, making your code more robust and less prone to errors. Combining these features with generics can significantly enhance the reusability and maintainability of your type definitions and functions.