12

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')
Jamie Treworgy
  • 23,934
  • 8
  • 76
  • 119

2 Answers2

13

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

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • 1
    This is pretty awesome. And I have to confess when I wrote this question I was absolutely trolling you because I'm a huge fan of your canon of TypeScript solutions on SO. So I just need to decide is the double-invocation worse than what I've been doing, e.g. `const furniture: { [key in Furniture]: true } = { chair: true, table: true, lamp: true };`. I think it's better; I care about order, and using a object where what I want is really an ordered set seems wrong, since then I have to (theoretically) worry about how consumers enumerate the keys. – Jamie Treworgy Mar 20 '19 at 19:59
  • 1
    Used your solution to define: `type Status = 'draft' | 'signed' | 'withdrawn'; const statuses = exhaustiveTuple()('draft', 'signed', 'withdrawn');` then immediately realized, i can do the opposite: `const statuses = ['draft', 'signed', 'withdrawn'] as const; type Status = typeof statuses[number];`. That way i don't have to type every string twice and the error message arising from exhaustiveTuple when something is missing is not very direct. – ips Jan 10 '23 at 14:31
0

I have other proposition

type RemoveFirstFromTuple<T extends any[]> = 
  T extends [] ? undefined :
  (((...b: T) => void) extends (a: any, ...b: infer I) => void ? I : [])

const tuple = <T extends string[]>(...args: T) => args;

type FurnitureUnion = 'chair' | 'table' | 'lamp';
type FurnitureTuple = ['chair', 'table' , 'lamp'];

type Check<Union, Tuple extends Array<any>> = {
  "error": never,
  "next": Check<Union, RemoveFirstFromTuple<Tuple>>,
  "exit": true,
}[Tuple extends [] ? "exit" : Tuple[0] extends Union ? "next" : "error"];

type R = Check<FurnitureUnion, FurnitureTuple>; // true
type R1 = Check<'chair' | 'lamp' | 'table', FurnitureTuple>; // true
type R2 = Check<'chair' | 'lamp' | 'table', ['chair', 'table' , 'lamp', 'error']>; // nerver

Remove from tuple takes tuple and return tuple without first element (will be needed later)

Check will iterate on Tuple. Each step can return never when Tuple[0] don't extend Union, exit when input tuple is empty and next when Tuple[0] extends Union. In next step we recursive call Check but first we remove first element from Tuple by previous util

Playground

Przemyslaw Jan Beigert
  • 2,351
  • 3
  • 14
  • 19
  • 1
    Just FYI it does not work under strict. I would not recommend using recusive type aliases like this, I believe it is actively discouraged and might not work in the future. – Titian Cernicova-Dragomir Mar 20 '19 at 17:09
  • I agree that is not readable solution. Thanks for tip with strict mode, i fix it not it works. I'm not sure about future of TS. On microsoft roadmap there is no mentions about breaking changes. – Przemyslaw Jan Beigert Mar 20 '19 at 17:15
  • 1
    @jcalz thanks to the PR pointed out by [your comment here](https://github.com/Microsoft/TypeScript/issues/26980#issuecomment-674465012) does this now mean that this kind of recursive type-aliasing is now okay? Because it seems quite clever to me – Geoff Davids May 08 '22 at 15:10