DEV Community

Apify for Apify

Posted on • Originally published at blog.apify.com on

TypeScript utility types: when and how to use them

The Apify SDK supports TypeScript by covering public APIs with type declarations. This allows writing code with auto-completion for TypeScript and JavaScript code alike. This article was written to provide you with a deeper knowledge of TypeScript. But if you want to know more about how it compares with JavaScript for web scraping, you might like to read TypeScript vs. JavaScript: which to use for web scraping?

What is TypeScript?

TypeScript is a superset of JavaScript that provides you with the capabilities of static type-checking, enabling you to catch type-related errors during development. One very useful feature of TypeScript is being able to define and manipulate types effectively to write code that is maintainable and reliable.

Utility types in TypeScript play a significant role in this regard, as they enable you to create new types based on existing ones.

What are utility types?

Utility types are sets of built-in generic types in TypeScript that allow you to create, manipulate, or create new types by applying specific modifications and transformations to existing types.

Implementing utility types in your TypeScript project can make working with types more flexible, expressive, and reusable. Declaring your own custom utility types or using TypeScript's built-in utility types are the two approaches to adding utility types to your code base.

TypeScript built-in utility types

There are different built-in utility types that come along with the TypeScript language to make type transformations without you needing to install a library or create a custom type to use them. Lets look at some of them:

1. Partial<Type>

When defined, the partial utility type in TypeScript turns all of a type's properties into optional fields. This allows you to modify the type's fields in part without TypeScript throwing errors.

For example, say you have User data in your application and want to update the information of the user. You only want to update specific fields and not the whole data. Using the Partial utility type, you can transform the data fields of the Userobject from required fields to optional fields.

// Description of the user datainterface User { id: number; firstName: string; lastName: string; email: string; bio: string;};// This is the user data fetchedconst userData: User = { id: 12345, firstName: "Jamin", lastName: "Doe", email: "hellojamin@test.com", bio: "Legendary Gamer and everything in between",};//Function to update the user infoconst updateUserInfo = (userId: number, updatedInfo: Partial<User>) => { // Logic to update the user's info with the provided data // Using the partial type, all fields becomes optional updatedInfo.lastName; //(property) lastName?: string | undefined updatedInfo.email; //(property) email?: string | undefined};// Example usageconst userId = 12345;const updatedInfo: Partial<User> = { firstName: "John", bio: "Web Developer",};updateUserInfo(userId, updatedInfo);
Enter fullscreen mode Exit fullscreen mode

In the example above, the Partial utility type converts all the User object properties to optional fields.

2. Required<Type>

The Required utility type is the opposite of the Partial utility type. It transforms all the fields of your Type into required fields. Imagine you have a data type of UserRegistration with some optional fields, but youd like to make all the fields required when using the data. You can achieve this using the Required utility type.

interface UserRegistration { username?: string; password?: string; email: string; fullName?: string;}const registerUser = (userData: Required<UserRegistration>) => { // Logic to register the user with the provided data};// Example usageconst userData: Required<UserRegistration> = { email: "user@example.com",};// throws an error// Type '{ email: string; }' is missing the following properties from type 'Required<UserRegistration>': username, password, fullNameregisterUser(userData);
Enter fullscreen mode Exit fullscreen mode

3. Pick<Type, Keys>

This utility type enables you to selectively pick properties from a Type using the Keys properties of the object you want to pick from.

Lets say you have a product catalog in your application and would like to list available products for a catalog without showing all the data properties of the products. You can create a simplified version of the original product object for listing available products.

// Let's create a simplified version of the product for the product listingtype SimplifiedProduct = Pick<Product, "id" | "name" | "price" | "category">;const product: Product = { id: "1001", name: "Laptop", description: "High-performance laptop", price: 1200, category: "Electronics", stock: 10 // ...};const simplifiedProduct: SimplifiedProduct = { id: product.id, name: product.name, price: product.price, category: product.category};console.log(simplifiedProduct);// Output: { id: "1001, name: "Laptop", price: 1200, category: "Electronics" }
Enter fullscreen mode Exit fullscreen mode

The Pick utility type can be very useful when you have a very complex data object and only want to display a fraction of that data object to your users.

4. Omit<Type, Keys>

The Omit<Type, Keys> type removes specified Keys properties from the Type and provides you with a Type without those properties.

// Let's create a simplified version of the product by excluding certain propertiestype SimplifiedProduct = Omit<Product, "description" | "stock">;const product: Product = { id: "1001", name: "Laptop", description: "High-performance laptop", price: 1200, category: "Electronics", stock: 10 // ...};//description and stock are excluded from the product propertiesconst simplifiedProduct: SimplifiedProduct = { id: product.id, name: product.name, price: product.price, category: product.category};console.log(simplifiedProduct);// Output: { id: "1001, name: "Laptop", price: 1200, category: "Electronics" }
Enter fullscreen mode Exit fullscreen mode

5. ReturnType<Type>

The return type is used to get a functions return type. This enables you to extract and use the type that a function will return when invoked. It works by taking in a function as its parameter and returning the Type of the returned value of the function.

Consider the fetchDataFromApi example below and how the return type of the function was derived using the ReturnType utility type.

type ApiResponse = { success: boolean; data: any; // Assuming data can be of any type};type ApiFetchFunction = () => Promise<ApiResponse>;function fetchDataFromApi(endpoint: string): ApiFetchFunction { // Simulating fetching data from the API const fetchFunction: ApiFetchFunction = async () => { const response = await fetch(endpoint); const data = await response.json(); return { success: true, data }; }; return fetchFunction;}// Usageconst fetchProductData = fetchDataFromApi('https://api.example.com/products');// Get the return type from the functiontype ProductDataResponse = ReturnType<typeof fetchProductData>;// Use the function's return typeasync function handleProductData() { const response: ProductDataResponse = await fetchProductData(); console.log('Product data:', response.data);}
Enter fullscreen mode Exit fullscreen mode

6. Awaited<Type>

The Awaited type is used for asynchronous functions and operations to determine the data type that the function or operation would resolve. Consider the product example you used previously. Imagine you need to fetch the product data from an API. The API service returns a Promise with the product data. You want to handle this asynchronously and extract the type of the resolved data.

// A function that simulates fetching product data from an APIconst fetchProductFromAPI = (): Promise<Product> => { return new Promise(resolve => { // Simulate an asynchronous API call setTimeout(() => { resolve({ id: "1001", name: "Laptop", description: "High-performance laptop", price: 1200, category: "Electronics", stock: 10 }); }, 1000); });};// Use ReturnType to get the return type of the async function and use// Awaited to retrieve the type of the async callconst getProductData = async (): Promise<Awaited<ReturnType<typeof fetchProductFromAPI>>> => { const productData = await fetchProductFromAPI(); return productData;};getProductData().then(data => { console.log("Product data:", data); /* Output: Product data: { id: '1001', name: 'Laptop', description: 'High-performance laptop', price: 1200, category: 'Electronics', stock: 10 } */});
Enter fullscreen mode Exit fullscreen mode

It's interesting to note that the Awaited utility transforms types in a recursive manner. So, no matter how deeply nested a Promise is, it will always resolve its value. For example, the code below would transform the nested async request to a single data type.

type Data = Awaited<Promise<Promise<string>>>;//type Data = string
Enter fullscreen mode Exit fullscreen mode

7. Record<Keys, Type>

The Record utility type enables you to construct an object type with Keys as its property keys and Type as its property values. The Keys passed to the Record ensure that only those specific keys can be assigned values of Type. This is particularly useful when you want to narrow down your records by only accepting specific keys.

Lets use a real-world scenario to understand this better:

You're creating a notification system for an application that notifies your users based on specific actions they take or responses to a request they make.

You can use the Record utility like this:

type NotificationTypes = 'error' | 'success' | 'warning';type IconTypes = 'errorIcon' | 'successIcon' | 'warningIcon';type IconColors = 'red' | 'green' | 'yellow';const notificationIcons: Record< NotificationTypes, { iconType: IconTypes; iconColor: IconColors }> = { error: { iconType: 'errorIcon', iconColor: 'red' }, success: { iconType: 'successIcon', iconColor: 'green' }, warning: { iconType: 'warningIcon', iconColor: 'yellow' }};console.log(notificationIcons.error)// OUTPUT: { iconType: 'errorIcon', iconColor: 'red' }
Enter fullscreen mode Exit fullscreen mode

In the example above, you specified the Keys for the object to be a Union type of either error, success, or warning and assigned them to a property value of { iconType: IconTypes; iconColor: IconColors }.

With this, youve created a constraint of records where the notificationIcons can only be accessed by one of the NotificationTypes. Trying to access the notificationIcons without a known NotificationTypes will throw an error:

console.log(notificationIcons.completed)// Property 'completed' does not exist on type 'Record<NotificationTypes, { iconType: IconTypes; iconColor: IconColors; }>'
Enter fullscreen mode Exit fullscreen mode

8. Readonly<Type>

The Readonly type transforms the object properties of a Type to 'read-only' so its values cannot be reassigned after initialization.

Example: Suppose you have a configuration object for a web application with various settings, and you want to ensure that once the configuration is set, it cannot be modified.

interface AppConfig { apiUrl: string; maxRequestsPerMinute: number; analyticsEnabled: boolean;}const initialConfig: Readonly<AppConfig> = { apiUrl: '<https://api.example.com>', maxRequestsPerMinute: 1000, analyticsEnabled: true,};// Attempt to modify a property (will result in a TypeScript error)initialConfig.apiUrl = '<https://new-api.example.com>';// Error: Cannot assign to 'apiUrl' because it is a read-only property.function displayConfig(config: Readonly<AppConfig>) { console.log('API URL:', config.apiUrl); console.log('Max Requests Per Minute:', config.maxRequestsPerMinute); console.log('Analytics Enabled:', config.analyticsEnabled);}displayConfig(initialConfig);
Enter fullscreen mode Exit fullscreen mode

9. NonNullable<Type>

The NonNullable type transforms a type by removing all null and undefined from the input Type passed to it.

Example: Let's say you have a union type that accepts a string, or a number, as values. The type can sometimes be (undefined or null) as optional values. In the case where you dont want to accept null or undefined when reusing this type, you can stripe off the null and undefined fields using NonNullable.

type UserID = string | number | null | undefined//Works fine as expectedconst userByIDString = "100"// error: Type 'undefined' is not assignable to type 'NonNullable<UserID>'const userByID: NonNullable<UserID> = undefined//Works fine as expectedconst userByIDNumber: NonNullable<UserID> = 102
Enter fullscreen mode Exit fullscreen mode

For a list of all the available built-in utility types, check out the official TypeScript documentation.

Custom types in TypeScript

Aside from using the built-in utility type from TypeScript, the language also offers the flexibility to create custom utility types to suit your needs where needed. Lets create a custom type in TypeScript that you can use to transform other types. For this example, youll create a utility type that accepts Type as an object type and filters the keys of the object by the Keypassed to the utility type.

// Custom utility type declaration type FilterKeysByType<Type, KeyType> = { [key in keyof Type as Type[key] extends KeyType ? key: never]: Type[key];}// Usage exampleinterface Person { name: string; age: number; email: string; isAdmin: boolean;}type StringKeys = FilterKeysByType<Person, string>;//OUTPUT: { name: string, email: string }
Enter fullscreen mode Exit fullscreen mode

Lets break down the custom utility line by line to understand it better,

  • type FilterKeysByType<Type, KeyType> is the type definition, and it accepts two things: the Type you want to transform and the KeyType to filter by.

  • In the second line, three major things are happening:

Custom types vs. utility types

Should you create custom types over TypeScript's built-in utility types?

That depends on the level of transformation and abstraction you're performing. If you want to perform complex transformations that go beyond what TypeScript's built-in features offer, you should consider creating your own custom types.

Utility types are built-in features of TypeScript, and they don't require any external libraries or more lines of code for you to define and use them. Since they're built-in features of TypeScript, they're well-known and familiar to most developers.

Combining custom types with utility types

Utilizing TypeScripts capabilities to create custom types lets you combine a custom type with a utility type to create a customized utility type that is tailored to fit the needs of your project.

Lets explore this with an example. Say you want to create a custom type that transforms another type to make specific properties of that type optional. You can create such a custom type by utilizing TypeScripts already existing utility types to achieve this:

type PartialBy<Type, Key extends keyof Type> = Omit<Type, Key> & Partial<Pick<Type, Key>>;// Usageinterface User {id: string;name: string;email: string;}const partialUser: PartialBy<User, 'email'> = {id: '123',name: 'John Doe',};// email is optional in partialUserconsole.log(partialUser);
Enter fullscreen mode Exit fullscreen mode

In this example, PartialBy is a utility type that takes two parameters: Type, which is the original type, and Key, which represents the keys that you want to make partial. It uses Omit to remove the specified keys from the original type and Partial<Pick> to make those keys optional.

With this, the PartialBy custom utility can transform the Key of any Type passed to it.

Summing up: why you should use utility types

Utility types in TypeScript can help you write sturdy, maintainable types. You can use them to make your type declarations more flexible, expressive, and reusable. They also give you the ability to combine them with custom types to create more powerful type declarations.

Top comments (0)