1

Assume

abstract class CustomisationProvider {
  abstract fetch(id: string): any;
}

I want to add type definitions the fetchThings function

function fetchThings(sources, ids, services){
  for(const s in sources){
    services[s].fetch(ids[s]);
  }
}

One valid type annotation that is accepted by the typescript compiler is

function fetchThings<
  IDs extends { [key: string]: any }
>(
    sources: string[],
    ids: IDs,
    services: {
      [key: string]: CustomisationProvider
    }
  ){
  for(const s in sources){
    services[s].fetch(ids[s]);
  }
}

However, this does not capture the fact that the elements in sources are expected to be key of services and keys of ids and that ids[source[i]] is string.

Starting from this answer I would expect the following to work

type KeysMatching<T, V> = {[K in keyof T]-?: T[K] extends V ? K : never}[keyof T];

function fetchThings<
  P extends KeysMatching<IDs, string>, 
  IDs extends { [key: string]: any }
>(
    sources: P[],
    ids: IDs,
    services: {
      [key: P]: CustomisationProvider
    }
  ){
  for(const s in sources){
    services[s].fetch(ids[s]);
  }
}

However, the TS compiler 4.3.5 will reject this with the message An index signature parameter type must be either 'string' or 'number'.

function fetchThings<
  P extends keyof IDs, 
  IDs extends { [key: string]: any }
>(
    sources: P[],
    ids: IDs,
    services: {
      [key: P]: CustomisationProvider
    }
  ){
  for(const s in sources){
    services[s].fetch(ids[s]);
  }
}

It will fail, what is very intriguing, since if P is key of IDs why it could not be a key of services. I have read that we cannot mix keys of different types.

i.e.


interface Y {
    [key: string ]: any,
    [key: number ]: any
}

is valid

but

interface X {
    [key: string | number ]: any
}

is not.

I tried to explicitly use type P & string, or Filter<P, string> where type Filter<T, U> = T extends U ? T : never; without success.

How could this type of relation to be represented in the type signatures?

Thank you.

Bob
  • 13,867
  • 1
  • 5
  • 27

2 Answers2

1

Try this:

abstract class CustomisationProvider {
  abstract fetch(id: string): any;
}

type KeysMatching<T, V> = { [K in keyof T]-?: T[K] extends V ? K : never }[keyof T];

function fetchThings<
  P extends KeysMatching<IDs, string>,
  IDs extends { [key: string]: any }
>(
  sources: P[],
  ids: IDs,
  services: Record<P, CustomisationProvider>
) {
  for (const s in sources) {
    services[s as P].fetch(ids[s]);
  }

TypeScript does not preserve specific index types in a loop.

It might be obvious that s has P type, but TS infers it to string. Same behavior for Object.keys(), it always infers to string[].

 for(const s /* s is a stirng */ in sources){
    services[s].fetch(ids[s]);
  }

Hence, in order to make it work, the easiest way is to make type assertion: as P

1

Going to suggest a slightly alternative solution.

It seemed like your typesafety really hinges on your Ids or Services so I simply made Ids the origin of the derivative types.

This allows s to be used as a string in sources since the keys in IDS are inherently strings.

This also allows you to have typesafety when providing arguments to fetchThings.

function fetchThings<
    KEYS extends keyof IDS,
    IDS extends { [key: string]: any },
    SERVICES extends Record<keyof IDS, CustomisationProvider>
>(
    sources: KEYS[],
    ids: IDS,
    services: SERVICES
) {
    for (const s in sources) {
        services[s].fetch(ids[s]);
    }
}

// works
fetchThings(['two'], {
    'one': 1,
    'two': 'two',
}, {
    'one': 1,
    'two': 2,
})

// throws an error
fetchThings(['two', 'three'], { // "three" is not assignable to "two" | "one"
    'one': 1,
    'two': 'two',
}, {
    'one': 1,
    'two': 2,
})

// throws an error
fetchThings(['two'], {
    'one': 1,
    'two': 'two',
}, {
    'one': 1, // missing key 'two' in object
})
Dane Brouwer
  • 2,827
  • 1
  • 22
  • 30
  • 1
    Thank you for your answer, I accepted the earliest. Your type exactly the expansion of `Record`. – Bob Jul 21 '21 at 12:41