TypeScript Conditional Scenarios
A look at 7 different TypeScript scenarios requiring dependent and conditional typing
I started on my TypeScript journey in December 2019. I'm still finding interesting bits that I want to explore and understand more. One such find was a tweet by Erik Rasmussen noting how the Material-UI team leverages types to enforce passing an array of values to a Select input when the multiple
prop is true. I found this fascinating and it lead me down a bit of a rabbit hole. In this post, I dig a bit further into conditional and dependent TypeScript types to further my own understanding.
Scenario 1: Optional Modifier
If you have worked with TypeScript at all, you are likely aware of the optional modifier . It designates a property as being required or not while still preserving type. Here is an example straight from the TypeScript docs:
interface PaintOptions {
shape: string;
xPos?: number;
yPos?: number;
}
let p0: PaintOptions = {shape: 'square'} // valid
let p1: PaintOptions = {shape: 'square', xPos: 1} // valid
let p2: PaintOptions = {shape: 'square', xPos: 1, yPos: 1} // valid
let p3: PaintOptions = {shape: 'square', yPos: 'asdf'} // invalid
In the above example, xPos
and yPos
are optional, BUT if present, must be numbers. This is a fairly rudimentary condition.
Scenario 2: Union Types
Another basic TypeScript concept is Union Types . This allows us to define a set of allowed types or literal values a property can be. Let's modify the above example to only allow the Shape
property to be either literal 'circle'
OR 'rectangle'
and allow yPos
to be either a number OR a string.
interface PaintOptions {
shape: 'circle' | 'rectangle'; // required literals
xPos?: number; // optional, but must be number
yPos?: number | string; // optional but must be a number or string
}
let p3: PaintOptions = {shape: 'rectangle', yPos: 'asdf'} // valid now
let p4: PaintOptions = {shape: 'circle', yPos: 'asdf'}
let p5: PaintOptions = {shape: 'triangle', yPos: 'asdf'} // invalid because we don't allow shape to be 'triangle`
Scenario 3: Using Union Types for Distinct Property Sets
In Scenario 2 above, we leveraged Union Types for simple conditionals using scaler types and literals. We can also union more complex types. In this example, we'll introduce two object types Circle
and Rectangle
to encapsulate the properties that define them both. We'll then attempt to make a Union Type that allows either of these types.
type Circle = {radius: number};
type Rectangle = {height: number, width: number};
type Shape = Circle | Rectangle;
With the above, you might assume that a shape can be either Circle
or Rectangle
. However, we actually end up with a mishmash that allows any of the types from both.
let circle: Shape = {radius: 5}
let rectangle: Shape = {height: 2, width: 4}
let weird: Shape = {height: 2, radius: 5}; // unexpectedly valid
let weird2: Shape = {height: 2, width: 6, radius: 5}; // unexpectedly valid
In this case, the Shape
type is a Union of the individual properties rather than the the Types themselves. This might be useful for some applications, but doesn't help us create the type of conditional logic we'd expect.
Enforcing Conditionals with literals
With the above, we can utilize a literal type on our individual types to create the conditions we expect. We'll give both Circle
and Rectangle
a new property name
and give them the literal values 'circle' and 'rectangle' respectively.
type Circle = {name: 'circle', radius: number};
type Rectangle = {name: 'rectangle', height: number, width: number};
type Shape = Circle | Rectangle;
Now when we use the same examples above, we get:
let circle: Shape = {name: 'circle', radius: 5}
let rectangle: Shape = {name: 'rectangle', height: 2, width: 4}
let weird: Shape = {name: 'circle', height: 2, radius: 5}; // now invalid
let weird2: Shape = {name: 'rectangle', height: 2, width: 6, radius: 5}; // now invalid
let weird3: Shape = {name: 'circle', height: 2}; // also invalid
Our weird Frankenstein type now behaves as expected: Circles can't have height. Rectangles don't have a radius.
🤔 By adding the name
property with literal values, I believe TypeScript is able to use a form of const assertions to prevent type widening, however, I'm not 100% on this terminology in this case. If you know more accurately what is going on here, feel free to post in the comments.
Scenario 4: Exclusive OR within a Type
In the above example, we created a type Shape
that allowed a variable to be one of two distinct types Circle
or Rectangle
but not both. A similar example to this might be to require two properties within a single type to be exclusive to each other.
For this scenario, we'll introduce a new type BorderSpec
with two logically conflicting properties borderWidth
and borderless
. This example might be a bit contrived, but pretend we can't have a borderWidth
if borderless
is true
and vice versa.
type BorderSpec = {borderWidth?: never, borderless: true}
| {borderWidth: number, borderless?: never | false}
let b1: BorderSpec = {borderWidth: 1};
let b2: BorderSpec = {borderless: true};
let b3: BorderSpec = {borderless: false, borderWidth: 1};
let b4: BorderSpec = {borderless: true, borderWidth: 1}; // Invalid - can't be borderless and have a width
This mostly builds off of the previous examples leveraging union types, optional modifiers, and literals. However, the new concept here is the [never](https://www.typescriptlang.org/docs/handbook/basic-types.html#never)
type. This type is mostly used as a return type for functions that always throw exceptions. However, we use it here to say that the subtype can never have the specific property present. It can't be null even.
Tying all the examples together thus far we get:
type Shape = BorderSpec & (Circle | Rectangle);
let circle: Shape = {name: 'circle', radius: 5, borderless: true}
let rectangle: Shape = {name: 'rectangle', height: 2, width: 4, borderless: true}
let ring: Shape = {name: 'circle', radius: 5, borderWidth: 1}
let frame: Shape = {name: 'rectangle', height: 2, width: 4, borderless: false, borderWidth: 1}
I think this is pretty neat!
Let's Make it Generic Let's take everything we just did and utilize another tool in the TypeScript toolbox: generics. I won't go into how they work, but it should be evident by the example of what they do.
type Shape<T> = BorderSpec & T; // Was a Union Type
let circle: Shape<Circle> = {name: 'circle', radius: 5, borderless: true}
let rectangle: Shape<Rectangle> = {name: 'rectangle', height: 2, width: 4, borderless: true}
let ring: Shape<Circle> = {name: 'circle', radius: 5, borderWidth: 1}
let frame: Shape<Rectangle> = {name: 'rectangle', height: 2, width: 4, borderless: false, borderWidth: 1}
This doesn't give us anything we didn't already have, but does decouple the Shape
type from Circle
and Rectangle
so we can introduce more shapes later in a less intrusive way. It also sets up the next example a bit.
Scenario 6: Ternary Operations on Boolean Generics
Let's take a step back and simplify our code. A ternary operator is one that takes 3 inputs - a conditional statement, a statement if the condition is met, and a statement if the condition is not met. I generally hate seeing these in the wild. However, one benefit of them is that they themselves are a single statement - unlike a proper if/then condition. As such, they can be used inline when an if/then might not otherwise be allowed ... such as in a type definition.
To demonstrate this, we will make a generic type Boolish
that "returns" a string stating if the argument to the generic is boolean true.
type Boolish<T> = T extends boolean ? 'isBool' : 'notBool';
// Try it out
let f0: Boolish<false> = 'isBool';
let f1: Boolish<0> = 'notBool';
let f2: Boolish<null> = 'notBool';
let f3: Boolish<undefined> = 'notBool';
let t0: Boolish<true> = 'isBool';
let t1: Boolish<1> = 'notBool';
Given the above, we have a new conditional tool available to us.
🤔 As you might be able to tell from the code, I wanted to test if other types evaluated to a "truthy" value, but after some exploring, I was unable to get it to work. I may investigate further.
Scenario 7: Select Input
With the above scenarios out of the way, we arrive at the reason reason I started down this path - what is going on in the Material-UI Select input that allows value
to be an array when multiple
is true?
First, we'll de-reactify the code from the original Tweet so it is pure TypeScript.
type Value<T, Multiple> = Multiple extends true ? Array<T> : T;
interface InputProps<T, Multiple extends boolean | undefined> {
value: Value<T, Multiple>;
multiple?: Multiple;
}
function Input<T, Multiple extends boolean | undefined>(props: InputProps<T, Multiple>) {
return 'whatever'
}
Picking this apart a bit:
The
Value
Generic type takes in two type arguments:T
andMultiple
.T
is used to type the Value (i.e. string, number, etc). IfMultiple
is true (and thus our conditional expression evaluates to true), the "output" type is an Array of elements of typeT
. Otherwise, it is justT
(i.e. a single scalar type).The
InputProps
interface is also generic and takes in the sameT
as above and technically the sameMultiple
as above, but allows it to be undefined. This then sets its propertyvalue
to be ourValue<T, Multiple>
(again an Array of typeT
or simplyT
).Finally, the
Input
function takes in a props argument that must match the above Generic conditions.
Let's try it out:
// Props
let multiNumProps: InputProps<number, true> = {value: [1,2,3,4], multiple: true}; // valid
let stringProps: InputProps<string, false> = {value: 'asdf', multiple: false}; // valid
let stringPropsImplicit: InputProps<string, undefined> = {value: 'asdf'}; // valid
let invalidMultiProps: InputProps<number, false> = {value: [1,2,3,4], multiple: false}; // invalid: Not multiple but array given
// Input
let c1 = Input<number, true>(multiNumProps); // Valid
let c2 = Input<string, false>(stringProps); // Valid
let c3 = Input<string, undefined>(stringPropsImplicit); // Valid
let cInvalid = Input<string, false>(multiNumProps); // Invalid because expecting string, not multi
That's pretty neat.
This all seems pretty verbose, but this was designed for React, which obfuscates the typing a bit so we get the enforcement, but also get the clean interface of React.
Wrapping Up
TypeScript is a a great way to enforce consistency in your code. By leveraging some of these conditional situations, you can further reduce the number of bugs. As always, TypeScript runs at compile time, so always be sure to use run time validation for user input.
If you want to explore some additional scenarios, check out these links:
- Is it possible to restrict number to a certain range *stackoverflow
- React.js TypeScript Conditional Props - Props that depend on other Props youtube
Happy Coding.