Typescript Explained In Javascript: Conditional Types
4th post in "TypeScript explained in JavaScript" series
Spencer Miskoviak
August 1, 2020
Photo by John Barkiple
Throughout the course of writing software, it's likely you'll eventually have a need to conditionally return different values from a function depending on it's input. Or, maybe a second parameter that depends on the value of the first parameter.
This concept is easy to handle with JavaScript at runtime, but is not as trivial to strictly type at compile time.
JavaScript
Let's start with a JavaScript function that accepts an array and allows filtering, or omitting any values of a specific type.
const omitByType = (array, type) => {
return array.filter(value => typeof value !== type);
};
console.log(omitByType(["a", 10, "b", false, "c"], "boolean"));
// ["a", 10, "b", "c"]
console.log(omitByType(["a", 10, "b", false, "c"], "number"));
// ["a", "b", false, "c"]
console.log(omitByType(["a", 10, "b", false, "c"], "string"));
// [10, false]
console.log(omitByType(["a", 10, "b", false, "c"], "object"));
// ["a", 10, "b", false, "c"]
Each invocation uses the same input, but changes the second argument. In each case, values of that type are filtered out.
- The first case no longer contains
false
because it's aboolean
. - The second no longer contains
10
because it's anumber
. - The third case no longer contains
"a"
,"b"
, or"c"
since they're all astring
. - The last case returns the same values since none of them are an
object
.
Should this last example be allowed? In this case it's an unnecessary operation. This isn't straightforward to fix at runtime, but could this be solved instead at compile time with strict typings? How would this constraint be reflected in the type system?
TypeScript
This is a simple function, but requires fairly complex typings so let's start with more basic typings and build from there.
const omitByType = (array: any[], type: string): any[] => {
return array.filter(value => typeof value !== type);
};
These typings are an incremental improvement since they at least require the
first parameter to be an array and the second parameter to be a string
. This
eliminates entirely invalid parameters, but is not strict enough to catch
a type
that is not contained within the array
.
// Now invalid...
omitByType("invalid", "object");
omitByType([], Object);
// Still valid...
omitByType([1], "object");
So how do we make this last example invalid? The challenge is that the type
parameter is dependent (conditional) on the type of the array
contents. This
requires conditional types. To start, we know we need access to the type
of the array
parameter which can be achieved with a generic.
const omitByType = <InputArray extends any[], Type extends string>(
array: InputArray,
type: Type
): any[] => {
return array.filter(value => typeof value !== type);
};
If you're not familiar with the
extends
keyword, I recommend first reading a previous post in this series that covers it in more depth.
This updated definition is equivalent to the original, but now the type
InputArray
can be referenced in other parts of the function deceleration.
Type
still allows any string, so let's constrain it based on the type of
InputArray
.
const omitByType = <InputArray extends any[], Type extends InputArray[number]>(
array: InputArray,
type: Type
): any[] => {
return array.filter(value => typeof value !== type);
};
Now, the Type
generic parameter must extend a type of the array contents.
InputArray[number]
returns a type (likely a union) that represents all of the
values in the array. However, this isn't quite correct yet:
// Argument of type '"number"' is not assignable to parameter of type 'number'.
omitByType([1], "number");
The problem is that Type
is now inferred as the type number
, not the string
"number"
. How do we convert the type number
to the string "number"
?
This is our first use for a conditional type. To start, let's only handle
the number
type.
type TypeToString<Type> = Type extends number ? "number" : "unknown";
type ExampleOne = TypeToString<number>; // "number"
type ExampleTwo = TypeToString<string>; // "unknown"
The TypeToString
type is a conditional type. There are two main aspects to
focus on:
- This is another use case for the
extends
keyword. It behaves in a similar way as it's other uses to constrain types. In this case, I like to think of it as a boolean condition, doesType
extendnumber
? - It uses the same syntax as JavaScript's ternary operator.
The condition
Type extends number
will either be true or false. If it's true, it will evaluate to"number"
, otherwise it will evaluate to"unknown"
.
This syntax is the basis for all conditional types. The mental model
of an if
statement in JavaScript can help reason through complex conditional
types. This isn't a direct comparison, but this could be roughly compared to
something like the following function in JavaScript.
const typeToString = type => {
if (typeof type === "number") {
return "number";
} else {
return "unknown";
}
};
Or, using the more comparable ternary operator syntax:
const typeToString = type => (typeof type === "number" ? "number" : "unknown");
Now that we have the basics of conditional types we can complete the typings.
First, TypeToString
only handles number
but we want to handle more types.
type TypeToString<Type> = Type extends string
? "string"
: Type extends number
? "number"
: Type extends boolean
? "boolean"
: Type extends undefined
? "undefined"
: Type extends Function
? "function"
: "object";
This is fairly verbose, but this would be the equivalent of nesting five if
statements, or a large if...else if...else
statement.
The function can now be updated to use this helper.
const omitByType = <
InputArray extends any[],
Type extends TypeToString<InputArray[number]>
>(
array: InputArray,
type: Type
): any[] => {
return array.filter(value => typeof value !== type);
};
This no longers allows a type
that doesn't exist in the array
!
// Argument of type '"object"' is not assignable to parameter of type '"number"'.
omitByType([1], "object");
To recap so far:
InputArray
is a generic that represents the type ofarray
. It also has a constraint that it must be an array enforced withextends any[]
.Type
is also a generic that represents the type oftype
. However, it has a more complex constraint. First,InputArray[number]
will return a type that represents all of the array's values. If thearray
was[1]
then the type would benumber
, if[1, "a", true]
then the type would benumber | string | boolean
. However, we need these types as string literals so we pass this type to theTypeToString
helper which uses conditional types to convert that value to"number"
or"number" | "string" | "boolean"
, respectively.
So trying to pass "object"
will not be allowed (unless the array
contained
something like [1, {}]
).
The parameters are now strictly typed, but the return value is still any[]
.
Let's improve this. We could use InputArray
which would be more strict, but
it would still contain the type of values that get filtered. So how can the
return value return the same contents excluding the values of the type removed?
First, we need a way to exclude a type from another type. For example, if given
a union string | number
how can number
be excluded, or string
? This again
can be achieved with conditional types.
type Diff<Types, TypesToExclude> = Types extends TypesToExclude ? never : Types;
type Example1 = Diff<string | number | boolean, string>; // number | boolean
type Example2 = Diff<string | number | boolean, number | string>; // boolean
type Example3 = Diff<string | number | boolean, object | boolean>; // string | number
Fortunately, the type Diff
doesn't need to be defined because TypeScript
predefines several conditional types. One of those is Exclude
which is identical
to the Diff
implementation above.
Now that we can exclude one type from another, the type of the array contents
is the first type argument and the type being excluded is the second type
argument. However, the Type
generic is now a string (eg: "number"
) so it
needs to be converted back to a type. For this, let's define yet another
conditional type StringToType
that is the inverse of TypeToString
.
With that, we have all the pieces we need for a complete example.
type TypeToString<Type> = Type extends string
? "string"
: Type extends number
? "number"
: Type extends boolean
? "boolean"
: Type extends undefined
? "undefined"
: Type extends Function
? "function"
: "object";
type StringToType<Str> = Str extends "string"
? string
: Str extends "number"
? number
: Str extends "boolean"
? boolean
: Str extends "undefined"
? undefined
: Str extends "function"
? Function
: object;
const omitByType = <
InputArray extends any[],
Type extends TypeToString<InputArray[number]>
>(
array: InputArray,
type: Type
): Array<Exclude<InputArray[number], StringToType<Type>>> => {
return array.filter(value => typeof value !== type);
};
To recap the return value:
- First, we are getting the type of the array contents using
InputArray[number]
which will return a type, usually a union if the array contains mixed values (eg:string | number
). - Then, we are converting the
Type
which is a string literal back to a type (eg:"string"
->string
). - Then, these two arguments are passed to
Exclude
to remove the values of the type that is being filtered out. Finally, that type is then redefined as the contents of anArray
.
Definition
Conditional types were introduced in TypeScript 2.8.
A conditional type selects one of two possible types based on a condition expressed as a type relationship test:
T extends U ? X : Y
. - TypeScript Documentation
Conclusion
Conditional types are often combined in complex ways which can make them overwhelming at first. When taking a step back, the syntax for conditional types is already a familiar concept in JavaScript and behaves in a very similar way.
This example covered one use case, but when combined with other concepts, such as mapped types it can unlock even more possibilities.
Tags:
course
Practical Abstract Syntax Trees
Learn the fundamentals of abstract syntax trees, what they are, how they work, and dive into several practical use cases of abstract syntax trees to maintain a JavaScript codebase.
Check out the course