typescript

keyof of multiple interfaces


I would like to have autocompletion for a function that takes a key of an object as a parameter and determines the type of the second parameter based on the key. If I only use a single interface as the source of the key, everything works fine:

interface A {
  X: {params: {a: string; b: number}};
  Y: {params: {c: number; d: number}};
}

const foo = <T extends keyof A>(k: T, params: A[T]["params"]) => {
  console.log(k, params);
};

foo("X", {a: "a", b: 1}); // works
foo("Y", {c: 1, d: "a"}); // correct error: Type 'string' is not assignable to type 'number'.

However, as soon as I add a second interface as a possible source, TypeScript tells me that I can no longer use the generic type as an index:

interface A {
  X: {params: {a: string; b: number}};
  Y: {params: {c: number; d: number}};
}

interface B {
  Z: {params: {e: string; f: string}};
}

const foo = <T extends keyof A | keyof B>(k: T, params: A[T]["params"] | B[T]["params"]) => {
  console.log(k, params);
};
// errors:
// * Type 'T' cannot be used to index type 'A'.
// * Type '"params"' cannot be used to index type 'B[T]'.

How do I have to type params of the foo function to make it work?


UPDATE

For the example above, it would be sufficient to combine A & B into one interface (thanks @jcalz). But for my use case this is not enough. I have a new example here, which hopefully shows more clearly what I want.

interface A {
  X: {params: {a: string; b: number}};
  Y: {params: {c: number; d: number}};
}

interface Props {
  [key: string]: {params: any};
}

type AdditionalProps = Props & {[K in keyof A]: never};

interface B extends AdditionalProps {
  Z: {params: {e: string; f: string}};
}

class C<AP extends AdditionalProps> {
    // error: Type 'T' cannot be used to index type 'A'.
    foo = <T extends keyof A | keyof AP>(k: T, params: A[T]["params"] | AP[T]["params"]) => {
    console.log(k, params);
  };
}

const c = new C<B>();

// works - without autocompletion for parameter `k`, but for `params` if `k` is a key of interface `A`
c.foo("X", { a: "", b: 1 })

// No autocomplete for `k`. If `"Z"` is entered for `k`, `params` has type `never`
c.foo("Z", ) 

SOLUTION

Here is the slightly modified solution from @Alexander Nenashev with working auto-completion.

interface A {
  X: {params: {a: string; b: number}};
  Y: {params: {c: number; d: number}};
}

type Props<T> = {
  [key in keyof T]: {params: any};
};

type AdditionalProps<T extends object = object> = Props<T> & {[K in keyof A]: never};

interface B extends AdditionalProps {
  Z: {params: {e: string; f: string}};
}

type ByKey<K, A, B> = K extends keyof A ? A[K] : K extends keyof B ? B[K] : never;

class C<AP extends AdditionalProps<AP>> {
  foo = <T extends keyof A | keyof AP>(k: T, params: ByKey<T, A, B>["params"]) => {
    console.log(k, params);
  };
}

const c = new C<B>();

c.foo("X", {a: "", b: 1});
c.foo("Y", {c: 1, d: 2});
c.foo("Z", {e: "1", f: "2"});
c.foo("D", {e: "1", f: "2"}); // correct error: Argument of type '"D"' is not assignable to parameter of type '"X" | "Y" | "Z"'

Solution

  • Given that you could use or couldn't the same keys you could utilize some conditional typing:

    Playground

    interface A {
      X: {params: {a: string; b: number}};
      Y: {params: {c: number; d: number}};
    }
    
    interface B {
      X: {params: {a: number; b: number}};
      Z: {params: {e: string; f: string}};
    }
    
    
    type ChooseByKey<K extends any> = K extends keyof A ? K extends keyof B ? (A|B)[K] : A[K] : K extends keyof B ? B[K]: never
    const foo = <K extends keyof A | keyof B>(k: K, params: ChooseByKey<K>['params']) => {
      console.log(k, params);
    };
    
    foo('X', {a: '1', b: 1});
    foo('X', {a: 1, b: 1});
    foo('Y', {c: 1, d: 1});
    foo('Y', {c: 1, d: 's'}); // error