Conditional Types in TypeScript.
Omotoso Abosede Racheal
Posted on July 26, 2024
Introduction
Conditional types in TypeScript allow you to express type relationships using a form of ternary logic at the type level. They provide a way to perform type checks and return different types based on those checks. The syntax and basic usage of conditional types are similar to JavaScript's ternary operator.
Conditional types help describe the relation between the types of inputs and outputs.
T extend U ? X : Y
Prerequisite
- Javascript
What you will learn
Some Conditional types examples
Conditional Type Constraint
Inferring Within Conditional Types
Distributive Conditional Types
Some Conditional types examples
Conditional types take a form that looks a little like conditional expressions (condition ? trueExpression : falseExpression) in JavaScript:
SomeType extends OtherType ? TrueType : FalseType;
When the type on the left of the extends is assignable to the one on the right, then you’ll get the type in the first branch (the “true” branch); otherwise, you’ll get the type in the latter branch (the “false” branch).
let’s take another example for a createLabel function:
interface IdLabel {
id: number /* some fields */;
}
interface NameLabel {
name: string /* other fields */;
}
function createLabel(id: number): IdLabel;
function createLabel(name: string): NameLabel;
function createLabel(nameOrId: string | number): IdLabel | NameLabel;
function createLabel(nameOrId: string | number): IdLabel | NameLabel {
throw "unimplemented";
}
These overloads for createLabel describe a single JavaScript function that makes a choice based on the types of its inputs. Note a few things:
- If a library has to make the same sort of choice over and over throughout its API, this becomes cumbersome.
- We have to create three overloads: one for each case when we’re sure of the type (one for string and one for number), and one for the most general case (taking a string | number). For every new type createLabel can handle, the number of overloads grows exponentially.
Instead, we can encode that logic in a conditional type:
type NameOrId<T extends number | string> = T extends number
? IdLabel
: NameLabel;
We can then use that conditional type to simplify our overloads down to a single function with no overloads.
function createLabel<T extends number | string>(idOrName: T): NameOrId<T> {
throw "unimplemented";
}
let a = createLabel("typescript");
let a: NameLabel
let b = createLabel(2.8);
let b: IdLabel
let c = createLabel(Math.random() ? "hello" : 42);
let c: NameLabel | IdLabel
Conditional Type Constraint
In TypeScript, a conditional type constraint is a way to impose restrictions (constraint) on type parameters in generic types using conditional types. This technique allows you to express more complex type relationships and ensure that type parameters meet certain criteria.
Let's start with a basic example to illustrate how conditional type constraints work:
type CheckType<T> = T extends string ? "String Type" : "Other Type";
type A = CheckType<string>; // "String Type"
type B = CheckType<number>; // "Other Type"
In this example, CheckType is a conditional type that checks if the type T extends string. If it does, it returns "String Type"; otherwise, it returns "Other Type".
Using Conditional Type Constraints in Generics
Conditional type constraints are often used within generic types to enforce more specific type rules.
Example: Constraining Function Parameters
Here's an example where a conditional type constraint ensures that a function parameter must be a specific type:
type EnsureString<T> = T extends string ? T : never;
function logString<T>(value: EnsureString<T>): void {
console.log(value);
}
logString("Hello"); // Works
logString(42); // Error: Argument of type '42' is not assignable to parameter of type 'never'.
In this example, the EnsureString type ensures that T must be a string. If T is not a string, the type becomes never, which is an uninhabitable type and causes a type error.
More Complex Example: Constraining Based on Object Properties
You can also use conditional type constraints to enforce more complex rules, such as ensuring an object has specific properties:
type HasName<T> = T extends { name: string } ? T : never;
function greet<T>(obj: HasName<T>): void {
console.log(`Hello, ${obj.name}`);
}
greet({ name: "Alice" }); // Works
greet({ name: "Bob", age: 30 }); // Works
greet({ age: 30 }); // Error: Argument of type '{ age: number; }' is not assignable to parameter of type 'never'.
In this example, the HasName type ensures that T must be an object with a name property of type string. If T does not have this property, the type becomes never, causing a type error.
Inferring Within Conditional Types
In TypeScript, you can use the infer keyword within conditional types to introduce a type variable that can be inferred from a specific part of a type. This is particularly useful when working with complex types such as functions, tuples, and arrays, where you might want to extract a particular type of component.
The syntax for using infer within a conditional type is as follows:
type SomeType<T> = T extends SomeCondition ? infer U : FallbackType;
Here, infer U allows TypeScript to infer the type U from T if T satisfies SomeCondition.
Examples
Extracting the Return Type of a Function
You can use infer to extract the return type of a function type:
type GetReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type Func = (a: number, b: string) => boolean;
type ReturnTypeOfFunc = GetReturnType<Func>; // boolean
In this example, GetReturnType checks if T is a function type. If it is, infer R captures the return type of the function, and GetReturnType resolves to that return type (R). Otherwise, it resolves to never.
Extracting the Element Type of an Array
You can use infer to extract the type of elements in an array:
type ElementType<T> = T extends (infer U)[] ? U : never;
type ArrayOfNumbers = number[];
type NumberElementType = ElementType<ArrayOfNumbers>; // number
Here, ElementType checks if T is an array type. If it is, infer U captures the element type, and ElementType resolves to that element type (U). Otherwise, it resolves to never.
Extracting Tuple Types
You can use infer to work with tuple types and extract their component types:
type FirstElement<T> = T extends [infer U, ...any[]] ? U : never;
type Tuple = [string, number, boolean];
type FirstType = FirstElement<Tuple>; // string
In this example, FirstElement checks if T is a tuple type. If it is, infer U captures the type of the first element, and FirstElement resolves to that type (U). Otherwise, it resolves to never.
Advanced Example: Extracting Parameter Types of a Function
You can use infer to extract the types of the parameters of a function:
type GetParameters<T> = T extends (...args: infer P) => any ? P : never;
type Func = (a: number, b: string) => void;
type ParametersOfFunc = GetParameters<Func>; // [number, string]
In this example, GetParameters checks if T is a function type. If it is, infer P captures the parameter types as a tuple, and GetParameters resolves to that tuple type (P). Otherwise, it resolves to never.
Distributive Conditional Types
When conditional types act on a generic type, they become distributive when given a union type. This means that if you have a conditional type T and T is a union, the conditional type will be applied to each member of the union.
For example, take the following:
type ToArray<Type> = Type extends any ? Type[] : never;
If we plug a union type into ToArray, then the conditional type will be applied to each member of that union.
type ToArray<Type> = Type extends any ? Type[] : never;
type StrArrOrNumArr = ToArray<string | number>;
type StrArrOrNumArr = string[] | number[]
What happens here is that ToArray distributes on:
string | number;
and maps over each member type of the union, to what is effectively:
ToArray<string> | ToArray<number>;
which leaves us with:
string[] | number[];
Typically, distributivity is the desired behavior. To avoid that behavior, you can surround each side of the extends keyword with square brackets.
type ToArrayNonDist<Type> = [Type] extends [any] ? Type[] : never;
// 'ArrOfStrOrNum' is no longer a union.
type ArrOfStrOrNum = ToArrayNonDist<string | number>;
type ArrOfStrOrNum = (string | number)[]
Conclusion
Conditional types in Typescript are powerful feature that enables advanced type manipulation and can help create more precise and flexible type definitions. They are particularly useful in generic programming and when working with complex type transformation.
Posted on July 26, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.