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';
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;
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>>;
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;
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;
How it Works:
Function Type Comparison: The type
Equal
creates two function types. Each function returns either1
or2
based on a conditional type check.Conditional Types:
T extends X ? 1 : 2
checks if a hypothetical typeT
would extend typeX
. If it does, the function returns1
, otherwise2
. The same check is done forY
.Extends Check for Functions: The key part is comparing these two function types. If
X
andY
are exactly the same, any typeT
will extend bothX
andY
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)
.Resulting in True or False: If the function types are equivalent, it means
X
andY
are exactly the same (strictly equal), so the whole type evaluates totrue
. Otherwise, it evaluates tofalse
.
Example:
type IsStringEqualToString = Equal<string, string>; // true
type IsStringEqualToNumber = Equal<string, number>; // false
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;
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 theinfer
keyword. - We use a custom
Equal
type to strictly compareFirst
withU
. If they're equal, we returntrue
. - 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
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)