1

Given this union type:

type Type = 'one' | 'two' | 'three'

How can I type the following object to ensure that it a) covers every possible value of Type; and b) allows me to have per-type function signatures?

const factories = {
    one(a: string) { return /* whatever */ },
    two(a: number, b: number) { return /* whatever */ },
    three() { return /* whatever */ }
}

function getFactory<T extends keyof typeof factories>(type: T): typeof factories[T] {
    return factories[type]
}

If I don't type the object, like above, I have full type checking but not exhaustivity - I can easily forget or misspell a member. In my real-life use case, Type is 30+ possibilities and growing so this really matters.

If I use a Record, such as const factories: Record<Type, any> or const factories: Record<Type, Function>, it have exhaustivity but I loose type checking of the function signatures.

4 Answers4

2

If you just want typescript to throw an error when it's missing a key, then just put a cast after the declaration:

const factories = {
    one(a: string) { return /* whatever */ },
    two(a: number, b: number) { return /* whatever */ },
    three() { return /* whatever */ }
};

factories as Record<Type, any>;

If you change three to foo, then the cast will error saying that there are incompatible error types. You could alternatively use a function that forces typescript to narrow the key types down to Type:

type Type = 'one' | 'two' | 'three';

function forceTypeKey<T>(obj: Record<Type, any> & T): T {
    return obj;
}

const factories = forceTypeKey({
    one(a: string) { return /* whatever */ },
    two(a: number, b: number) { return /* whatever */ },
    three() { return /* whatever */ }
});
Aplet123
  • 33,825
  • 1
  • 29
  • 55
  • Smart. I would prefer a solution that does not imply extra code generation, but if I don't get one within the next few days, I'll accept your answer. Thanks. – Stephan Schreiber Dec 07 '20 at 00:16
1
type Type = "one" | "two" | "three";

type Factories = {
  [K in Type]: typeof factories[K];
};

// It will become circular so you can't assign here
const factories = {
  one(a: string) {
    return; /* whatever */
  },
  two(a: number, b: number) {
    return; /* whatever */
  },
  three() {
    return; /* whatever */
  }
};

// But you will get an error due to lack of support
// for index type if your const factories is missing
// a key from type 
function getFactory<T extends Type>(type: T): Factories[T] {
  return factories[type];
}

const f = getFactory("one"); // will be (a: string) => void
makeitmorehuman
  • 11,287
  • 3
  • 52
  • 76
1

@Aplet123's answer is correct; I just wanted to follow up with a version that does not result in any changes to the emitted JavaScript code. Personally, I don't think that an extra line or two of JavaScript is a big deal, but if you'd like to completely restrict the effects of the type checking to the static type system, you could do it this way:

type ExhaustiveFactories<T extends Record<Type, any> =
  typeof factories> = void;

That line will be completely erased when the JavaScript is emitted. If it compiles, it's because factories has a property for every element of Type; otherwise, you'll get an error telling you which property or properties of Type are missing in factories, such as:

type Type = 'one' | 'two' | 'three';

const factories = {
  one(a: string) { return /* whatever */ },
  two(a: number, b: number) { return /* whatever */ },
}

type ExhaustiveFactories<T extends Record<Type, any> =
  typeof factories> = void; // error!
//~~~~~~~~~~~~~~~~ <-- Property "three" is missing

or:

const factories = {
  one(a: string) { return /* whatever */ },
  two(a: number, b: number) { return /* whatever */ },
  thwee() { return /* whatever */ },
}

type ExhaustiveFactories<T extends Record<Type, any> =
  typeof factories> = void; // error!
//~~~~~~~~~~~~~~~~ <-- Property "three" is missing

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Exactly what I was looking for, thank you! I just replaced `any` with `Function` because I really want all entries to be, well... functions. Also, I must point out, that this does not prevent me from adding properties that don't exist on `Type` - but *this* is not a big deal ;) – Stephan Schreiber Dec 07 '20 at 03:05
0

Here is an addition to jcalz's solution, that also checks for excess properties (see your comment):

type AssertAssignable<Expected, Actual extends Expected> = Actual

type AssertFactories =
  AssertAssignable<Record<Type, any>, typeof factories> &
  AssertAssignable<typeof factories, Record<Type, any>>
// Example
const factories = {
  one(a: string) { return ... },
  two(a: number, b: number) { return ... },
  three: "foo",
  four: 42  // <-- error
}

A more concise magic alternative:

type AssertFactories2<U extends (
  (<T>() => T extends typeof factories ? 1 : 2) extends
  (<T>() => T extends {[P in Type]: typeof factories[P] } ? 1 : 2) ? typeof factories : 
  "Sorry, excess or missing property in typeof factories"
  )
  = typeof factories> = U

This complex looking conditional type just makes sure, typeof factories is identical to {[P in Type]: typeof factories[P] }.

You can take a look at the playground.

A_blop
  • 792
  • 2
  • 11