TypeScript Type for Conditional Parameters with Compile-Time Validation
Tags: webdev, typescript, javascript
Published: 2023-02-26 12:43:44



## 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:

```typescript
interface ISomeParams {
  param1: string;
  param2: number;
  param3: string;
}
```

- Define the `IAlwaysRequiredParams` interface, which represents a set of required parameters:

```typescript
interface IAlwaysRequiredParams {
  requiredParam: number;
}
```
- Define the `TWithCheck` type, which represents a set of parameters that require `ISomeParams` and `IAlwaysRequiredParams` to be present:

```typescript
type TWithCheck = {
  checkParams: true;
} & IAlwaysRequiredParams & ISomeParams;
```
- Define the `TWithoutCheck` type, which represents a set of parameters that only require `IAlwaysRequiredParams` to be present.

```typescript
type TWithoutCheck = {
  checkParams: false;
} & IAlwaysRequiredParams;
```
Finally, define the `TConditionalParams` type as a union of `TWithCheck` and `TWithoutCheck`:

```typescript
export type TConditionalParams =
  | TWithCheck
  | TWithoutCheck;
```
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:
```typescript
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;
```

Function declaration:
```typescript
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;
  }
```
Code usage:
```typescript
// 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'.
```

## 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.