DEV Community

Nick Korolev
Nick Korolev

Posted on

TypeScript Type for Conditional Parameters with Compile-Time Validation

Introduction

Configurable objects are a common pattern in software development, allowing functions and APIs to be customized based on various parameters. However, ensuring that certain parameters are present or absent based on a certain condition can be a challenge. Consider the example of a function that generates SQL queries based on configurable search parameters. Some search parameters may be required for advanced search queries, but not for basic search queries. Without proper type-checking, it's easy to accidentally omit or include required parameters, leading to bugs and maintenance issues down the line.
Fortunately, TypeScript's advanced type system offers a powerful solution to this problem. By leveraging conditional types, mapped types, and intersection types, we can create type, which enforces parameter requirements at compile time based on a certain condition. We can define configurable objects that are both flexible and type-safe, making our code more reliable and easier to maintain. In this article, we'll explore how to use TypeScript's advanced types, and demonstrate its benefits through real-world examples.

TypeScript Advanced Types Overview

Brief overview of the different advanced types in TypeScript (e.g. mapped types, intersection types).

TypeScript's advanced types are a set of features that extend the type system beyond basic types such as strings, numbers, and booleans. These advanced types offer a more powerful way of describing complex types and defining relationships between them.

There are several advanced types in TypeScript, including:

  • Mapped Types: A type that transforms the properties of an existing type by mapping them to new properties. For example, a type that makes all properties of an existing type optional, or a type that adds a prefix or suffix to all property names.

  • Intersection Types: A type that combines two or more types into a single type, where the resulting type includes all properties and methods from each of the input types.

These advanced types can be used together to create complex and flexible types that are adapted to specific use cases.

Creating the type

Step-by-step guide to creating the type mapped types and intersection types.

Let's consider the code below. We'll create the type and name this TConditionalParams
To create TConditionalParams, we'll need to use a combination of mapped types, intersection types. Here's a step-by-step guide to creating the type:

  • Define the ISomeParams interface, which represents a set of optional parameters:
interface ISomeParams {
  param1: string;
  param2: number;
  param3: string;
}
Enter fullscreen mode Exit fullscreen mode
  • Define the IAlwaysRequiredParams interface, which represents a set of required parameters:
interface IAlwaysRequiredParams {
  requiredParam: number;
}
Enter fullscreen mode Exit fullscreen mode
  • Define the TWithCheck type, which represents a set of parameters that require ISomeParams and IAlwaysRequiredParams to be present:
type TWithCheck = {
  checkParams: true;
} & IAlwaysRequiredParams & ISomeParams;
Enter fullscreen mode Exit fullscreen mode
  • Define the TWithoutCheck type, which represents a set of parameters that only require IAlwaysRequiredParams to be present.
type TWithoutCheck = {
  checkParams: false;
} & IAlwaysRequiredParams;
Enter fullscreen mode Exit fullscreen mode

Finally, define the TConditionalParams type as a union of TWithCheck and TWithoutCheck:

export type TConditionalParams =
  | TWithCheck
  | TWithoutCheck;
Enter fullscreen mode Exit fullscreen mode

With this type definition, we can now create type-safe functions and APIs that allow for configurable objects with parameter requirements that are enforced at compile time. In the next section, we'll explore some examples of using the type in practice.

Explanation of how the type enforces parameter requirements at compile time based on a condition.

The condition in TConditionalParams is defined by the checkParams property, which is a boolean that determines whether to enforce additional parameters. When checkParams is true, the object must include all properties from both IAlwaysRequiredParams and ISomeParams. When checkParams is false, the object must include IAlwaysRequiredParams.

Read world example

Types definition:

interface Product {
  price: number;
  category: string;
  rating: number;
  name: string;
  id: number;
}

interface IAdvancesSearchParams {
  price: number;
  category: string;
  rating: number;
}
interface ISearchParams {
  keywords: string;
}
type TAdvancedSearch = {
  useAdvancedSearch: true;
} & IAdvancesSearchParams &
  ISearchParams;
type TBasicSearch = {
  useAdvancedSearch: false;
} & ISearchParams;
type TProductSearchParams = TBasicSearch | TAdvancedSearch;
Enter fullscreen mode Exit fullscreen mode

Function declaration:

function searchProducts(products: Array<Product>, params: TProductSearchParams): Array<Product> {
  const matchingProducts = products.filter(product => product.name.includes(params.keywords));
  if (params.useAdvancedSearch) {
      if (params.price === undefined || params.category === undefined || params.rating === undefined) {
        throw new Error('Missing required parameters!');
      }
      return matchingProducts.filter(
        product =>
          product.price <= params.price && product.category === params.category && product.rating >= params.rating
      );
    }
    return matchingProducts;
  }
Enter fullscreen mode Exit fullscreen mode

Code usage:

// Valid usage: useAdvancedSearch = true
const advancedResults = searchProducts([], {
  useAdvancedSearch: true,
  keywords: 'organic',
  price: 20,
  category: 'produce',
  rating: 4.5,
});
// Returns an array of products that match the advanced search criteria

// Valid usage: useAdvancedSearch = false
const basicResults = searchProducts([], {
  useAdvancedSearch: false,
  keywords: 'organic',
});
// Returns an array of products that match the basic search criteria

// Invalid usage: useAdvancedSearch = false
const invalidResults = searchProducts([], {
  useAdvancedSearch: false,
  keywords: 'organic',
  price: 20,
});
// Complitation error Object literal may only specify known properties, and 'price' does not exist in type 'TBasicSearch'.

// Invalid usage: useAdvancedSearch = true
const invalidResultsWithAdvancedSearch = searchProducts([], {
  useAdvancedSearch: true,
  keywords: 'organic',
  price: 20,
  category: 'produce',
});
// Compilation error Property 'rating' is missing in type '{ useAdvancedSearch: true; keywords: string; price: number; category: string; }' but required in type 'IAdvancesSearchParams'.
Enter fullscreen mode Exit fullscreen mode

Conclusion

In this article, we've explored how to use TypeScript's advanced types to create the type that enforces parameter requirements at compile time based on a certain condition. With this type, we can define configurable objects that are both flexible and type-safe, improving the reliability and maintainability of our code.
By leveraging mapped types and intersection types, we can create complex and flexible types that are adapted to specific use cases. Using these types, we can enforce parameter requirements at compile time, preventing runtime errors caused by missing or extra parameters. This makes our code more reliable and easier to maintain, especially as our codebase grows in complexity.

Top comments (0)