TypeScript Conditional Scenarios

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 and Multiple. T is used to type the Value (i.e. string, number, etc). If Multiple is true (and thus our conditional expression evaluates to true), the "output" type is an Array of elements of type T. Otherwise, it is just T (i.e. a single scalar type).

  • The InputProps interface is also generic and takes in the same T as above and technically the same Multiple as above, but allows it to be undefined. This then sets its property value to be our Value<T, Multiple> (again an Array of type T or simply T).

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

Happy Coding.