Nowadays, it’s difficult to imagine a serious JavaScript-based application without a TypeScript superset. Interfaces, tuples, generics, and other features are well-known among TypeScript developers. While some advanced constructs may require a learning curve, they can significantly bolster your type safety. This article aims to introduce you to some of these advanced features.
Type guards help us to get info about a type within a conditional block. There are a few simple ways to check the type using in
, typeof
, instanceof
operators, or using equality comparison (===
).
In this section, I’d like to pay more attention to user-defined type guards. This guard serves as a simple function that returns a boolean value. In other words, the return value is a type predicate.
Let’s take a look at the example when we have base user info and user 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’.
}
}
Copied
The isDetailedUser
function returns a boolean value, but it does not identify this function as a boolean that “defines the object type.”
In order to achieve the desired result, we need a little bit of update isDetailedUser
function using “user is DetailedUser”
construction
function isDetailedUser(user: User | DetailedUser): user is DetailedUser {
return ‘profile’ in user;
}
Copied
There may be the case in your app when you have a large object type and you want to create a new type, that uses a part of the original one. For example, part of our app requires only a user profile. User[‘profile’]
extracts the desired type and assigns it to the UserProfile
type.
type User = {
id: string;
name: string;
surname: string;
profile: {
birthday: string;
}
}
type UserProfile = User[‘profile’];
Copied
What if we want to create a type based on a few properties? In this case, you can use a built-in type called Pick.
type FullName = Pick<User, ‘name’ | ‘surname’>; // { name: string; surname: string }
Copied
There are many other utility types, such as Omit, Exclude, and Extract, which may be helpful for your app. At first sight, all of them are kind of indexed types, but actually, they are built on Mapped types.
You might have met the case when an app provided you with a union type, such as:
type UserRoleType = ‘admin’ | ‘user’ | ‘newcomer’;
Copied
Then, in another part of the app, we fetch user data and check its role. For this case, we need to create an array:
const ROLES: UserRoleType[] = [‘admin’, ‘user’, ‘newcomer’];
ROLES.includes(response.user_role);
Copied
Looks tiring, doesn’t it? We need to repeat union-type values inside our array. It would be great to have a feature to retrieve a type from an existing array to avoid duplication. Fortunately, indexed types help here as well.
First of all, we need to declare our array using a const assertion to remove the duplication and make a read-only tuple.
const ROLES = [‘admin’, ‘user’, ‘newcomer’] as const;
Copied
Then, using the typeof operator and number type, we create a union type based on the array value.
type RolesType = typeof ROLES[number]; // ‘admin’ | ‘‘user’ | ‘‘newcomer’;
Copied
You may be confused about this solution, but as you may know, arrays are object-based constructions with numeric keys. That’s why, in this example, number is used as the index access type.
Conditional types define a type that depends on the condition. Usually, they are used along with generics. Depending on the generic type (input type), construction chooses the output type.
For example, the built-in NonNullable
TypeScript type is built on conditional types.
type NonNullable<T> = T extends null | undefined ? never : T
type One = NonNullable<number>; // number
type Two = NonNullable<undefined>; // never
Copied
The infer keyword is used with conditional types and can not be used outside of the ‘extends
’ clause. It serves as a ‘type variable creator
.’
I think it will be easier for you to understand it by looking at the real example.
Case: retrieve async function result type.
const fetchUser = (): Promise<{ name: string }> => { /* implementation */ }
Copied
The easiest solution is to import the type declaration and assign it to the variable. Unfortunately, there are cases when result declaration is written inside the function, as in the example above.
This problem may be resolved in two steps:
1. The Awaited
utility type was introduced in TypeScript 4.5. For learning purposes, let’s look at the simplified variant.
export type Awaited<T> = T extends Promise<infer U> ? U : T;
Copied
Using conditional types and infer keyword, we “pull out” the promised type and assign it to the U
name. It’s a kind of type variable declaration. If the passed type is acceptable with PromiseLike
generic, construction returns the original type saved to the U
name.
2. Get value from the async function.
Using built-in ReturnType
that extracts the return type of function and our Awaited
type, we achieve the desired result:
export type AwaitedReturnType<T> = Awaited<ReturnType<T>>;
Copied
I hope you found this article useful for yourself. Have fun coding!
Originally published on DZone.