Search code examples
typescript

Enforce that an array is exhaustive over a union type


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')

Solution

  • 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