Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Enforce that an array is exhaustive over a union type

Tags:

typescript

Given a strongly-typed tuple created using a technique such as described here:

const tuple = <T extends string[]>(...args: T) => args;
const furniture = tuple('chair', 'table', 'lamp');

// typeof furniture[number] === 'chair' | 'table' | 'lamp'

I want to assert at design time that it's exhaustive over another union type:

type Furniture = 'chair' | 'table' | 'lamp' | 'ottoman'

How can I create a type that will ensure that furniture contains each and only the types in the Furniture union?

The goal is to be able to create an array at design time like this, and have it fail should Furniture change; an ideal syntax might look like:

const furniture = tuple<Furniture>('chair', 'table', 'lamp')
like image 710
Jamie Treworgy Avatar asked Mar 20 '19 16:03

Jamie Treworgy


People also ask

What is exhaustive mapping?

It's a mapped type containing one property for each type in Union , which is a function from that specific type to a string . This is exhaustive: if you leave out one of A or B , or put the B function on the A property, the compiler will complain.

What is the union type on TypeScript?

A union type describes a value that can be one of several types. We use the vertical bar ( | ) to separate each type, so number | string | boolean is the type of a value that can be a number , a string , or a boolean .

Does not exist on type Union?

The "property does not exist on type union" error occurs when we try to access a property that is not present on every object in the union type. To solve the error, use a type guard to ensure the property exists on the object before accessing it.

What is the name of the technique where you use a single field of literal types to let TypeScript narrow down the possible current type?

The in operator narrowing JavaScript has an operator for determining if an object has a property with a name: the in operator. TypeScript takes this into account as a way to narrow down potential types. For example, with the code: "value" in x . where "value" is a string literal and x is a union type.


1 Answers

TypeScript doesn't really have direct support for an "exhaustive array". You can guide the compiler into checking this, but it might be a bit messy for you. A stumbling block is the absence of partial type parameter inference (as requested in microsoft/TypeScript#26242). Here is my solution:

type Furniture = 'chair' | 'table' | 'lamp' | 'ottoman';

type AtLeastOne<T> = [T, ...T[]];

const exhaustiveStringTuple = <T extends string>() =>
  <L extends AtLeastOne<T>>(
    ...x: L extends any ? (
      Exclude<T, L[number]> extends never ? 
      L : 
      Exclude<T, L[number]>[]
    ) : never
  ) => x;


const missingFurniture = exhaustiveStringTuple<Furniture>()('chair', 'table', 'lamp');
// error, Argument of type '"chair"' is not assignable to parameter of type '"ottoman"'

const extraFurniture = exhaustiveStringTuple<Furniture>()(
  'chair', 'table', 'lamp', 'ottoman', 'bidet');
// error, "bidet" is not assignable to a parameter of type 'Furniture'

const furniture = exhaustiveStringTuple<Furniture>()('chair', 'table', 'lamp', 'ottoman');
// okay

As you can see, exhaustiveStringTuple is a curried function, whose sole purpose is to take a manually specified type parameter T and then return a new function which takes arguments whose types are constrained by T but inferred by the call. (The currying could be eliminated if we had proper partial type parameter inference.) In your case, T will be specified as Furniture. If all you care about is exhaustiveStringTuple<Furniture>(), then you can use that instead:

const furnitureTuple =
  <L extends AtLeastOne<Furniture>>(
    ...x: L extends any ? (
      Exclude<Furniture, L[number]> extends never ? L : Exclude<Furniture, L[number]>[]
    ) : never
  ) => x;

Playground link to code

like image 129
jcalz Avatar answered Oct 02 '22 14:10

jcalz