Skip to content

Exhaustive array on union type #53171

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
5 tasks done
mrjacobbloom opened this issue Mar 9, 2023 · 5 comments
Closed
5 tasks done

Exhaustive array on union type #53171

mrjacobbloom opened this issue Mar 9, 2023 · 5 comments

Comments

@mrjacobbloom
Copy link

Suggestion

Something that I've needed from time to time is a way to check that an array contains all the keys on an interface. Right now I can use (keyof SomeType)[] to verify that there are no typos/unknown keys, but there's no (easy) way to ensure that the array is exhaustive. I propose a new built-in type, ExhaustiveArray<SomeUnion>, to do that.

🔍 Search Terms

tuple, complete list, all keys, all union members, array

✅ Viability Checklist

My suggestion meets these guidelines:

  • This wouldn't be a breaking change in existing TypeScript/JavaScript code (all GH search results for ExhaustiveArray are in Java)
  • This wouldn't change the runtime behavior of existing JavaScript code
  • This could be implemented without emitting different JS based on the types of the expressions
  • This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, new syntax sugar for JS, etc.)
  • This feature would agree with the rest of TypeScript's Design Goals.

⭐ Suggestion

I'd like a way to check that an array contains all the keys on an interface. Right now I can do this:

interface MyInterface {
  foo: string;
  bar: string;
}

const keys: (keyof MyInterface)[] = ['foo', 'bar'];

...which will tell me if I've mistyped a key, but it won't tell me if a key is missing. So it's not future-proof if I add a new key to MyInterface. It'd be nice if I could do something like this instead:

const keys: ExhaustiveArray<keyof MyInterface> = ['foo'];
// Error: Element 'bar' is missing in type '['foo']' but required in type 'ExhaustiveArray<keyof MyInterface>'.

// And it pairs well with the `satisfies` operator:
const keys: (keyof MyInterface)[] = ['foo'] satisfies ExhaustiveArray<keyof MyInterface>;

The order of elements doesn't matter, but type-checking fails if the array doesn't contain exactly 1 of every union member. This is a benefit over ToTuple<SomeUnion> or something that depends on TypeScript's internal order of union members.

Generally, for a union X with 2 members, ExhaustiveArray<X> behaves the same as tuple type [X, X]:

type Union = 'foo' | 'bar';
type Arr = ExhaustiveArray<Union>;
type x = Arr['length']; // 2
type y = Arr[0]; // Union
type z = Arr[5]; // Error: Tuple type 'Arr' of length '2' has no element at index '5'.

It is an error to pass a union that contains a non-literal type:

type x = ExhaustiveArray<string>; // Error: An ExhaustiveArray can only take a union of literal types.
type y = ExhaustiveArray<'foo'>; // compiles

I'm not sure how I'd handle optional keys, but given keyof doesn't preserve that info anyway, it's probably moot. Maybe something like this?

const allKeys: AllKeys[] = [
  ...['foo'] satisfies ExhaustiveArray<RequiredKeys>,
  ...['bar'] satisfies OptionalKeys[]
];

📃 Motivating Example

💻 Use Cases

There is an existing way to do this (see stackoverflow answer), but the proposed solution is roundabout and affects the compiled code (introduces an unnecessary curried function call).

Other solutions might attempt to turn the union into a tuple. This depends on TypeScript's internal union member order, and these functions tend to be recursive which means they may fail for large unions.

The use case that motivated this was an attempt to iterate over a subset of keys in an object, to clear all errors from a state object:

interface Errors {
  error_foo: string;
  error_bar: string;
}

interface State extends Errors {
  foo: string;
}

const errorKeys: (keyof Errors)[] = ['error_foo']; // compiles even though `error_bar` is missing

function clearErrors(oldState: State): State {
  const newState = { ...oldState };
  for (const errorKey of errorKeys) newState[errorKey] = null;
  return newState;
}
@RyanCavanaugh
Copy link
Member

You can do this today with no runtime impact:

interface MyInterface {
  foo: string;
  bar: string;
  // missing: string;
}

interface CheckForMissing<Arr extends readonly unknown[], Keys extends Arr[number]> { }
interface CheckForExcess<Arr extends readonly Keys[], Keys> { }
const keys = ['foo', 'bar'/*, 'baz'*/] as const;
{
    
    type CheckKeys1 = CheckForMissing<typeof keys, keyof MyInterface>;
    type CheckKeys2 = CheckForExcess<typeof keys, keyof MyInterface>;
}

@mrjacobbloom
Copy link
Author

Oh cool! That's a little less ergonomic than what I was thinking, but that's fine given this is a less common use case. Thank you for the quick response!

@RyanCavanaugh
Copy link
Member

It's possible there's a more clever solution to do it in 1 line; maybe hit up the folks in the Discord if they're looking for a challenge.

@jakebailey
Copy link
Member

I was nerd sniped by a related issue and found these:

@yasammez
Copy link

yasammez commented Aug 9, 2023

Since I too stumbled down this particular rabbit hole, other readers might be interested in this library:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants