TypeScript, the statically-typed superset of JavaScript, is well-known for its powerful type system. As developers delve deeper into TypeScript, they discover advanced type inference techniques that can make code more expressive and robust. In this article, we’ll explore some of these advanced type inference features.
Conditional Types
Conditional types are a fascinating feature in TypeScript that allow us to conditionally select types based on some criteria. The infer
keyword within conditional types is especially intriguing. Consider the following example:
type IsArray<T> = T extends Array<any> ? true : false;
type Result = IsArray<number[]>; // Result is true
In this code, the IsArray
type checks whether the provided type T
is an array or not, returning true
if it is and false
otherwise. Conditional types are incredibly useful for creating complex type mappings.
Example:
function getUserInfo<T extends 'id' | 'name'>(key: T): T extends 'id' ? number : string {
if (key === 'id') {
return 123; // Assume this is a user ID (number)
} else {
return 'John Doe'; // Assume this is the user's name (string)
}
}
const userId: number = getUserInfo('id'); // Valid, userId is a number
const userName: string = getUserInfo('name'); // Valid, userName is a string
In this example:
- The
getUserInfo
function takes akey
parameter that can be either'id'
or'name'
. - We use a conditional type to specify that if
key
is'id'
, the function returns anumber
(the user’s ID), and ifkey
is'name'
, it returns astring
(the user’s name).
When you call getUserInfo('id')
, TypeScript infers that the return type is number
, and when you call getUserInfo('name')
, it infers that the return type is string
. This conditional typing allows you to have different return types based on the condition, making your code type-safe.
Mapped Types
Mapped types offer a way to create new types by transforming the properties of an existing type. Let’s take a common example: making all properties of an object optional.
type Partial<T> = {
[K in keyof T]?: T[K];
};
With this Partial
type, you can easily make any object’s properties optional. This can be a game-changer when you’re working with APIs and want to describe optional parameters.
Suppose you have an interface representing a basic user object:
interface User {
id: number;
name: string;
email: string;
}
Now, you want to create a new type that makes all properties of this user object optional. You can use a mapped type for this:
type PartialUser = {
[K in keyof User]?: User[K];
};
In this example:
[K in keyof User]
iterates over all the keys (id
,name
, andemail
) in theUser
interface.?: User[K]
makes each property optional by adding the?
modifier.
Now, let’s use the PartialUser
type:
const partialUser: PartialUser = {
id: 1,
};
Here, partialUser
is of type PartialUser
, and you’re allowed to omit properties like name
and email
while retaining the id
property. This demonstrates how mapped types can help you create new types that are based on the structure of existing types but with specific modifications.
Template Literal Types
Introduced in TypeScript 4.1, template literal types enable you to create string literal types by concatenating other string literals. Here’s an example:
type EventName = `on${string}`;
const onClick: EventName = 'onclick'; // Valid
Template literal types are a powerful tool for creating types that match specific string patterns, such as event names or API endpoints.
type EventName =
on${string};
: Here, you’re defining a new type calledEventName
. This type uses a template literal type to create string literal types. The template literalon${string}
specifies that theEventName
type should start with the string “on” and be followed by any string (${string}
). This means thatEventName
can only represent string values that start with “on”.const onClick: EventName = 'onclick';
: You’re declaring a constantonClick
with the typeEventName
. SinceEventName
is defined as a template literal type that starts with “on”, assigning the string literal'onclick'
to it is valid because it matches the pattern defined by the type.
Essentially, this code enforces that the onClick
variable can only hold string values that start with “on”. Any attempt to assign a string that doesn’t conform to this pattern would result in a TypeScript type error.
Here are some examples to illustrate the behavior:
const onClick1: EventName = 'onclick'; // Valid
const onClick2: EventName = 'onmousedown'; // Valid
const invalidEvent: EventName = 'hover'; // Error: Type '"hover"' is not assignable to type 'EventName'.
In the last example, TypeScript correctly identifies that the string 'hover'
does not match the expected pattern defined by the EventName
type and reports a type error. This demonstrates how template literal types can be used to create highly specific and constrained string literal types in TypeScript.
Infer in Conditional Types
The infer
keyword can be used within conditional types to extract types from other types. A common use case is creating a utility type to extract the return type of a function:
// Define a conditional type ReturnType<T> that extracts the return type of a function
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
// Define a simple function
function add(a: number, b: number): number {
return a + b;
}
// Use the ReturnType type to infer the return type of the 'add' function
type Result = ReturnType<typeof add>; // Result is number
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
: This defines a conditional typeReturnType
. It checks if the typeT
extends a function type that takes any number of arguments ((...args: any[])
) and uses theinfer
keyword to capture the inferred return typeR
. If the condition is met, it returnsR
; otherwise, it returnsnever
.function add(a: number, b: number): number { ... }
: This is a simple addition function that takes two numbers and returns their sum.type Result = ReturnType<typeof add>;
: Here, you’re using theReturnType
type to infer the return type of theadd
function. Sinceadd
returns anumber
, theResult
type is inferred asnumber
.
You can use this mechanism to automatically determine the return type of functions, which is especially helpful for cases where the return type depends on complex logic or input types.
Here are some additional examples:
function greet(name: string): string {
return `Hello, ${name}!`;
}
type GreetResult = ReturnType<typeof greet>; // GreetResult is string
function divide(a: number, b: number): number {
return a / b;
}
type DivideResult = ReturnType<typeof divide>; // DivideResult is number
In each case, TypeScript correctly infers the return type of the function, making your code more type-safe and maintainable.
Mapped Types with Conditional Types
Combining mapped types and conditional types can lead to advanced type transformations. For instance, you can create a mapped type that makes all methods of a class asynchronous:
Suppose you have an interface Person
representing individuals with different properties:
interface Person {
name: string;
age: number;
hasEmail: boolean;
}
Now, you want to create a new type that transforms all properties of Person to be optional if the property name starts with “has.” You can achieve this using mapped types with conditional types:
type TransformPerson<T> = {
[K in keyof T]: K extends `has${infer U}` ? boolean : T[K];
};
In this example:
[K in keyof T]
iterates over all the keys (name
,age
, andhasEmail
) in thePerson
interface.K extends
has${infer U}? boolean : T[K]
checks whether each property key starts with “has.” If it does, the property is transformed to a boolean type; otherwise, it remains unchanged.
Now, let’s use the TransformPerson
type:
const person: TransformPerson<Person> = {
name: 'Alice',
age: 30,
hasEmail: true,
};
const optionalPerson: Partial<Person> = person; // This works because properties are optional now
In this example:
person
is of typeTransformPerson<Person>
, which means that all properties starting with “has” are of typeboolean
, and other properties retain their original types.optionalPerson
is of typePartial<Person>
because all properties are now optional. This allows you to create an object where you can omit some or all properties safely.
Mapped types with conditional types are incredibly useful when you need to transform or modify properties based on certain conditions while preserving the type safety of the original interface.
Keyof and Lookup Types
The keyof
operator allows you to extract keys from an object and use them to access corresponding property types. For example:
Suppose you have an object representing a user with various properties:
const user = {
id: 1,
username: 'john_doe',
email: '[email protected]',
};
Now, let’s say you want to create a utility function that allows you to access the values of specific properties using their names. You can use keyof
to extract the keys of the object and lookup types to access their corresponding types:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const userId: number = getProperty(user, 'id'); // userId is inferred as number
const username: string = getProperty(user, 'username'); // username is inferred as string
const userEmail: string = getProperty(user, 'email'); // userEmail is inferred as string
In this example:
- The
getProperty
function takes two type parameters:T
(the type of the object) andK
(the type of the property key). - It accepts an object (
obj
) and a property key (key
), and it returns the value of the specified property from the object. - The
<K extends keyof T>
constraint ensures that the property key (K
) is a valid key of the object’s type (T
).
By using keyof T
, you can dynamically access the properties of the object while maintaining type safety. If you try to access a property that doesn’t exist on the object, TypeScript will catch the error at compile time.
Here are some additional examples:
const invalidProperty = getProperty(user, 'age'); // Error: Property 'age' does not exist on type '{ id: number; username: string; email: string; }'.
In this case, TypeScript correctly identifies that there is no ‘age’ property on the user
object and reports a type error.
Keyof and lookup types are especially useful when you need to work with dynamic property access or when you want to ensure that you’re using valid property names and their corresponding types at compile time.
Inference for Array Element Types
You can infer the element type of an array using conditional types and infer
:
Suppose you have an array with various types of elements:
const mixedArray = [1, 'two', true, { name: 'Alice' }];
Now, you want to create a utility function that infers the type of the elements within the array. You can use conditional types and inference for this purpose:
type ArrayElementType<T> = T extends (infer U)[] ? U : never;
function getFirstElement<T>(arr: T[]): ArrayElementType<T> {
return arr[0];
}
const firstElement: number | string | boolean | { name: string } = getFirstElement(mixedArray);
In this example:
- The
ArrayElementType<T>
type uses a conditional type to check ifT
extends an array of some type (T extends (infer U)[]
). If it does, it infers the element typeU
; otherwise, it returnsnever
. - The
getFirstElement
function takes an array of typeT
and returns the inferred element type usingArrayElementType<T>
.
When you call getFirstElement(mixedArray)
, TypeScript infers the type of firstElement
based on the elements in the mixedArray
. In this case, firstElement
is inferred as number | string | boolean | { name: string }
, which represents all possible types of elements in the array.
Here are some additional examples:
const numbers = [1, 2, 3, 4, 5];
const strings = ['apple', 'banana', 'cherry'];
const firstNumber: number = getFirstElement(numbers);
const firstString: string = getFirstElement(strings);
In each case, the getFirstElement
function dynamically infers the element type of the array and ensures type safety.
This is particularly useful when you’re working with arrays of unknown or heterogeneous types and need to perform operations based on the inferred element type.
Recursive Types
In TypeScript, you can create recursive types to model structures like linked lists or trees:
type ListNode<T> = { value: T; next?: ListNode<T> };
type LinkedList<T> = ListNode<T> | undefined;
// Create a linked list of numbers
const node1: ListNode<number> = { value: 1 };
const node2: ListNode<number> = { value: 2 };
const node3: ListNode<number> = { value: 3 };
node1.next = node2;
node2.next = node3;
// Linked list traversal
function printLinkedList<T>(head: LinkedList<T>) {
let current: LinkedList<T> = head;
while (current) {
console.log(current.value);
current = current.next;
}
}
// Print the linked list
printLinkedList(node1); // Outputs: 1, 2, 3
ListNode<T>
represents a node in the linked list, containing avalue
of typeT
and an optionalnext
property pointing to the next node in the list.LinkedList<T>
is a type alias that can be either aListNode<T>
orundefined
, representing the head of the linked list. It’sundefined
when the list is empty.- We create three nodes (
node1
,node2
, andnode3
) and link them together to form a simple linked list of numbers. - The
printLinkedList
function takes the head of the linked list and traverses it, printing each value.
When we call printLinkedList(node1)
, it prints the values 1
, 2
, and 3
, demonstrating how the recursive type ListNode<T>
allows us to create and work with linked lists in a type-safe manner.
This example showcases the power of recursive types in modeling recursive data structures, providing type safety and clarity in your code.
Recursive types enable you to define complex data structures with confidence.
You can also discover a lot about Javascript by exploring different topics.
Note: We welcome your feedback at Easy Coding School. Please don’t hesitate to share your suggestions or any issues you might have with the article!