6

I am trying to produce an events property on Klass which contains an array of strings that exactly matches all the keys of a given interface. Like so:

interface Events {
    one: (foo: string) => void
    two: (bar: number) => void
}

class Klass {
    protected readonly events: [keyof Events] = ['one', 'two']
}

However, the above errors out with the following:

[ts]
Type '["one", "two"]' is not assignable to type '["one" | "two"]'.
  Types of property 'length' are incompatible.
    Type '2' is not assignable to type '1'. [2322]
(property) Klass.events: ["one" | "two"]

What is needed here to ensure that the events property returns an array that contains all of the events?

balupton
  • 47,113
  • 32
  • 131
  • 182

3 Answers3

14

You can almost express this in the type system (assuming TS3.0+) with conditional types, with a few caveats:

type Invalid<T> = ["Needs to be all of", T]
const arrayOfAll = <T>() => <U extends T[]>(
  ...array: U & ([T] extends [U[number]] ? unknown : Invalid<T>[])
) => array;
const arrayOfAllEventKeys = arrayOfAll<keyof Events>();

const goodEvents = arrayOfAllEventKeys('one', 'two'); // okay, type ['one', 'two']

const extraEvents = arrayOfAllEventKeys('one', 'two', 'three'); // error
//                                                    ~~~~~~~ 
// Argument of type "three" is not assignable to parameter of type "one" | "two"

const missingEvents = arrayOfAllEventKeys('one'); // error
//                                        ~~~~~ 
// Argument of type "one" is not assignable to 
// parameter of type ["Needs to be all of", "one" | "two"]

const redundantEvents = arrayOfAllEventKeys('one', 'two', 'one'); // no error
// doesn't enforce distinctness

Note that goodEvents is inferred to be of type ['one', 'two'], and there is no error. That's what you want. You get errors on extra events and on missing events.

Caveat 1: The error for missing events is a bit cryptic; TypeScript doesn't yet support custom error messages, so I chose something that hopefully is somewhat understandable (Argument of type "one" is not assignable to parameter of type ["Needs to be all of", "one" | "two"]).

Caveat 2: There is no error for redundant events. There's no general way that I can find to require that each parameter to arrayOfAllEventKeys is of a distinct type that doesn't run afoul of some issues with recursive types. It's possible to use overloading or other similar techniques to work for arrays of up to some hardcoded length (say, 10), but I don't know if that would meet your needs. Let me know.

Hope that helps; good luck!

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Pls help me out. What does `[T] extends [U[number]` mean? I've found out that `T extends U[number]` means pretty much the same thing, except it shows no error when a key is missing. The former one must be probably saying `array of a single T item extends an array where the first item can be any key that is indexed by a number of type U`, but that does not explain to me why the array notation is needed. (I suppose that this is an array, because `[T, number] extends [U[number], number]` works just like your version. So I may have asked this already earlier, but what is it called? Thanks so much. – andras Apr 03 '19 at 17:40
  • 1
    `type F = T extends U ? X : Y` and `type G = [T] extends [U] ? X : Y` differ only in that `F` is a [distributive](https://github.com/Microsoft/TypeScript/wiki/What's-new-in-TypeScript#distributive-conditional-types) conditional type, and `G` is not. In `F`, `T` is a [naked type parameter](https://stackoverflow.com/a/55383816/2887218) but in `G` it is "clothed" in a [covariant](https://en.wikipedia.org/wiki/Covariance_and_contravariance_(computer_science)#Covariant_arrays_in_Java_and_C#) one-element [tuple](https://www.typescriptlang.org/docs/handbook/basic-types.html#tuple). – jcalz Apr 03 '19 at 18:01
3

You are putting the [] in the wrong place.

interface Events {
  one: (foo: string) => void
  two: (bar: number) => void
}

class Klass {
  protected readonly events: (keyof Events)[] = ['one', 'two']
}

Notice how I change [keyof Events] to (keyof Events)[].

Check this typescript playground to verify that there are no errors.

Aaron Turkel
  • 144
  • 1
  • 11
1

I think you need a concrete object to achieve your goal here. In the example below, the implementation of the interface gives you a guaranteed way to get the right list. If you add a new member to Events it will force you to add it to ConcreteEvents. The only potential issue would be if you add other members to ConcreteEvents.

interface Events {
    one: (foo: string) => void;
    two: (bar: number) => void;
}

class ConcreteEvents implements Events {
    // Note! Only add members of Events
    one(foo: string) { };
    two(bar: number) { };
}

class Klass {
    public readonly events = Object.keys(ConcreteEvents.prototype) as any as [keyof Events][];
}

const klass = new Klass();
console.log(klass.events);

You could achieve this in several different ways using a concrete class or an object, so you don't have to follow this example exactly.

Fenton
  • 241,084
  • 71
  • 387
  • 401