Today, it’s hard to imagine a robust JavaScript application without the use of TypeScript. Features like interfaces, tuples, and generics are widely familiar to TypeScript developers. While some advanced concepts might take extra effort to learn, they can greatly enhance type safety. This article will guide you through some of these advanced features.
Type Guards
Type guards allow us to determine the type of a value within a conditional block. There are several straightforward ways to perform type checks using operators like in, typeof, and instanceof, or by using an equality comparison (===).
In this section, the focus will be on user-defined type guards. These are simple functions that return a boolean value, acting as a type predicate. Let’s consider an example where we work with basic user information and users with additional details.
type User = { name: string };
type DetailedUser = {
name: string;
profile: {
birthday: string
}
}
function isDetailedUser(user: User | DetailedUser) {
return ‘profile’ in user;
}
function showDetails(user: User | DetailedUser) {
if (isDetailedUser(user)) {
console.log(user.profile); // Error: Property ‘profile’ does not exist on type ‘User | DetailedUser’.
}
}
The isDetailedUser function returns a boolean value, but it does not specify that this function determines the object’s type.
To achieve the intended outcome, the isDetailedUser function needs to be updated using the user is DetailedUser construct.
function isDetailedUser(user: User | DetailedUser): user is DetailedUser {
return ‘profile’ in user;
}
Indexed Access Types
In some cases, your application might involve a large object type, but you only need a portion of it to create a new type. For instance, if a specific part of the app only requires a user profile, you can use User['profile'] to extract the necessary type and assign it to a new UserProfile type.
type User = {
id: string;
name: string;
surname: string;
profile: {
birthday: string;
}
}
type UserProfile = User[‘profile’];
If you need to create a type based on a select few properties, you can make use of the built-in Pick type.
type FullName = Pick<User, ‘name’ | ‘surname’>; // { name: string; surname: string }
There are several other utility types, such as Omit, Exclude, and Extract, that can be useful for your application. While they may seem like indexed types at first glance, they are actually built upon Mapped types.
Indexed Types With an Array
You may have encountered a situation where an application provided a union type, such as:
type UserRoleType = ‘admin’ | ‘user’ | ‘newcomer’;
In another part of the application, user data is retrieved, and their role is checked. For this scenario, it’s necessary to create an array:
const ROLES: UserRoleType[] = [‘admin’, ‘user’, ‘newcomer’];
ROLES.includes(response.user_role);
Seems exhausting, doesn’t it? Repeating union-type values within the array can be tedious. It would be ideal to extract a type directly from an existing array to avoid redundancy. Luckily, indexed types provide a solution for this.
To start, we need to define our array using a const assertion. This helps eliminate duplication and creates a read-only tuple.
const ROLES = [‘admin’, ‘user’, ‘newcomer’] as const;
Next, by utilizing the typeof operator along with the number type, we can generate a union type derived from the values in the array.
type RolesType = typeof ROLES[number]; // ‘admin’ | ‘‘user’ | ‘‘newcomer’;
This approach might seem confusing at first, but it’s important to remember that arrays are object-based structures with numeric keys. For this reason, the number type is used as the index access type in this example.
Conditional Types and Infer Keyword
Conditional types define a type that varies based on a condition. They are often paired with generics to determine the output type based on the input type. For instance, TypeScript's built-in NonNullable type is implemented using conditional types.
type NonNullable<T> = T extends null | undefined ? never : T
type One = NonNullable<number>; // number
type Two = NonNullable<undefined>; // never
The infer keyword is specifically used with conditional types and is limited to the extends clause. Its primary purpose is to act as a "type variable creator." Understanding its functionality becomes clearer when examined through a practical example.
Case: retrieve async function result type.
const fetchUser = (): Promise<{ name: string }> => { /* implementation */ }
The simplest approach is to import the type declaration and assign it to a variable. However, there are situations where the result declaration is written directly inside the function, as shown in the example above.
This issue can be addressed in two steps:
1. The Awaited utility type, introduced in TypeScript 4.5, offers a solution. For learning purposes, let’s explore a simplified version.
export type Awaited<T> = T extends Promise<infer U> ? U : T;
By combining conditional types and the infer keyword, the promised type is extracted and assigned to Uname, effectively declaring a type variable. If the provided type is compatible with the PromiseLike generic, the construction returns the original type stored in Uname.
2. Extract the value from an async function
Using the built-in ReturnType, which retrieves the return type of a function, together with the custom Awaited type, we can achieve the intended result.
export type AwaitedReturnType<T> = Awaited<ReturnType<T>>;
Hopefully, this article was helpful to you. Enjoy coding!