TypeScript utility types: when and how to use them

Learn about utility types in TypeScript, when and how to use them, and how you can combine them with custom types.

Content

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. Let’s 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 data
interface User {
  id: number;
  firstName: string;
  lastName: string;
  email: string;
  bio: string;
};
// This is the user data fetched
const userData: User = {
  id: 12345,
  firstName: "Jamin",
  lastName: "Doe",
  email: "hellojamin@test.com",
  bio: "Legendary Gamer and everything in between",
};
//Function to update the user info
const 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 usage
const userId = 12345;
const updatedInfo: Partial<User> = {
  firstName: "John",
  bio: "Web Developer",
};
updateUserInfo(userId, updatedInfo);

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 you’d 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 usage
const userData: Required<UserRegistration> = {
  email: "user@example.com",
};
// throws an error
// Type '{ email: string; }' is missing the following properties from type 'Required<UserRegistration>': username, password, fullName

registerUser(userData);

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.

Let’s 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 listing
type 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" }

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 properties
type 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 properties
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" }

5. ReturnType<Type>

The return type is used to get a function’s 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;
}

// Usage
const fetchProductData = fetchDataFromApi('https://api.example.com/products');

// Get the return type from the function
type ProductDataResponse = ReturnType<typeof fetchProductData>;

// Use the function's return type
async function handleProductData() {
  const response: ProductDataResponse = await fetchProductData();
  console.log('Product data:', response.data);
}

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 API
const 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 call
const 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
    }
    */
});

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

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.

Let’s 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' }

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

With this, you’ve 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; }>'

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);

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 don’t 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 expected
const userByIDString = "100"

// error: Type 'undefined' is not assignable to type 'NonNullable<UserID>'
const userByID: NonNullable<UserID> = undefined

//Works fine as expected
const userByIDNumber: NonNullable<UserID> = 102

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. Let’s create a custom type in TypeScript that you can use to transform other types. For this example, you’ll 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 example
interface Person {
  name: string;
  age: number;
  email: string;
  isAdmin: boolean;
}

type StringKeys = FilterKeysByType<Person, string>;
//OUTPUT: { name: string, email: string }

Let’s 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:
    • First, key in keyof Type as Type[key] is mapping through the key properties of Type.
    • Next, there's a conditional extends KeyType ? key: never that checks whether each key of Type has the KeyType that was passed to the utility type.
    • Finally, once the correct key is obtained, it's mapped to the correct Type using the key:Type[key].

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 TypeScript’s 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.

Let’s 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 TypeScript’s already existing utility types to achieve this:

type PartialBy<Type, Key extends keyof Type> = Omit<Type, Key> & Partial<Pick<Type, Key>>;

// Usage
interface User {
id: string;
name: string;
email: string;
}

const partialUser: PartialBy<User, 'email'> = {
id: '123',
name: 'John Doe',
};
// email is optional in partialUser

console.log(partialUser);

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.

Trust Jamin
Trust Jamin
Software engineer and technical writer passionate about open-source, web development, cloud-native development, and web scraping technologies. Currently learning GoLang

Get started now

Step up your web scraping and automation