DEV Community

Alexander Opalic
Alexander Opalic

Posted on • Edited on

Implementing a Custom `Includes` Utility Type in TypeScript

Introduction

TypeScript offers a powerful type system that allows developers to create complex type-safe applications. One of the more advanced techniques in TypeScript is creating utility types, which can enhance type safety and code readability. Today, we're going to dive into creating a custom Includes utility type, exploring several key TypeScript concepts along the way.

What is the Includes Utility Type?

The Includes utility type checks if a given type is included in a tuple or an array type. It's similar in concept to JavaScript's array .includes() method, but for types. Implementing Includes in TypeScript is an excellent way to understand some of the more nuanced features of the language.

Key TypeScript Concepts

Before we start, let's discuss some TypeScript concepts that are crucial for understanding our implementation:

Conditional Types

Conditional types in TypeScript allow you to define a type that can have different forms based on some condition. It's like an if statement, but for types.

type Check<T> = T extends string ? 'String' : 'Not String';
Enter fullscreen mode Exit fullscreen mode

Infer Keyword

The infer keyword is used within conditional type branches to infer types within other types. It's often used in tuple and function types.

type ElementType<T> = T extends (infer U)[] ? U : never;
Enter fullscreen mode Exit fullscreen mode

Recursive Types

Recursive types are types that reference themselves in their definition. They're useful for defining types that need to work through a structure of unknown depth, like linked lists or tree structures.

type RecursiveArray<T> = Array<T | RecursiveArray<T>>;
Enter fullscreen mode Exit fullscreen mode

Strict Type Comparison

TypeScript's structural type system is quite permissive. This means that TypeScript focuses on the shape that values have. For instance, if two different interfaces have the same shape (the same properties with the same types), TypeScript will consider them to be compatible, even if they were declared separately.

interface Person {
  name: string;
}

interface User {
  name: string;
}

let person: Person;
let user: User;

// TypeScript considers these types compatible due to structural typing.
person = user;
user = person;
Enter fullscreen mode Exit fullscreen mode

Need for Strict Type Comparison

There are scenarios where you need to distinguish types more strictly, beyond just their structure. For example, you might want to ensure that two types are exactly the same and not just structurally compatible. This is where strict type comparison comes into play.

Implementing Strict Type Comparison

To implement strict type comparison, you can use a clever combination of conditional types and the infer keyword. The Equal type uses a higher-order function technique to compare two types.

type Equal<X, Y> = 
  (<T>() => T extends X ? 1 : 2) extends 
  (<T>() => T extends Y ? 1 : 2) ? true : false;
Enter fullscreen mode Exit fullscreen mode
How it Works:
  1. Function Type Comparison: The type Equal creates two function types. Each function returns either 1 or 2 based on a conditional type check.

  2. Conditional Types: T extends X ? 1 : 2 checks if a hypothetical type T would extend type X. If it does, the function returns 1, otherwise 2. The same check is done for Y.

  3. Extends Check for Functions: The key part is comparing these two function types. If X and Y are exactly the same, any type T will extend both X and Y in the same way, making the two function types identical. Therefore, (<T>() => T extends X ? 1 : 2) will be considered equivalent to (<T>() => T extends Y ? 1 : 2).

  4. Resulting in True or False: If the function types are equivalent, it means X and Y are exactly the same (strictly equal), so the whole type evaluates to true. Otherwise, it evaluates to false.

Example:
type IsStringEqualToString = Equal<string, string>; // true
type IsStringEqualToNumber = Equal<string, number>; // false
Enter fullscreen mode Exit fullscreen mode

Here, IsStringEqualToString evaluates to true because string is strictly equal to string. Conversely, IsStringEqualToNumber evaluates to false because string is not strictly equal to number.

Implementing Includes

Now, let's combine these concepts to create our Includes utility type:

type Includes<T extends readonly any[], U> = T extends [infer First, ...infer Rest]
  ? Equal<First, U> extends true
    ? true
    : Includes<Rest, U>
  : false;
Enter fullscreen mode Exit fullscreen mode

Here's what's happening:

  • We use conditional types to check each element of the tuple.
  • T extends [infer First, ...infer Rest] splits the tuple into its first element and the rest. This is a form of tuple destructuring using the infer keyword.
  • We use a custom Equal type to strictly compare First with U. If they're equal, we return true.
  • If not, we recursively call Includes with the rest of the tuple (Rest).

Testing Our Includes Type

To ensure our utility type works correctly, let's test it with a few examples:

type Test1 = Includes<['a', 'b', 'c'], 'a'>; // true
type Test2 = Includes<['a', 'b', 'c'], 'd'>; // false
type Test3 = Includes<[1, 2, 3], 2>;          // true
type Test4 = Includes<[1, 2, 3], 4>;          // false
Enter fullscreen mode Exit fullscreen mode

Conclusion

Creating custom utility types like Includes is a great way to dive deeper into TypeScript's type system. It helps you understand and leverage advanced concepts such as conditional types, recursive types, and strict type comparisons. Not only does this enhance your TypeScript skills, but it also leads to more robust and maintainable code.

TypeScript's type system is deep and powerful, and mastering it can greatly improve the quality of your code. Happy typing!

Top comments (0)