4

I'm struggling with how to strongly type some functionality with TypeScript.

Essentially I have a function that accepts a key/value map of DataProviders and returns a key/value map of the data returned from each. Here's a simplified version of the problem:

interface DataProvider<TData> {
    getData(): TData;
}

interface DataProviders {
    [name: string]: DataProvider<any>;
}

function getDataFromProviders<TDataProviders extends DataProviders>(
    providers: TDataProviders): any {

    const result = {};

    for (const name of Object.getOwnPropertyNames(providers)) {
        result[name] = providers[name].getData();
    }

    return result;
}

Currently getDataFromProviders has a return type of any but I want it so that if called like so...

const values = getDataFromProviders({
    ten: { getData: () => 10 },
    greet: { getData: () => 'hi' }
});

...then values will be implicitly strongly typed as:

{
    ten: number;
    greet: string;
}

I imagine this would involve returning a generic type with a generic parameter of TDataProviders but I can't quite work it out.

This is the best I can come up with but doesn't compile...

type DataFromDataProvider<TDataProvider extends DataProvider<TData>> = TData;

type DataFromDataProviders<TDataProviders extends DataProviders> = {
    [K in keyof TDataProviders]: DataFromDataProvider<TDataProviders[K]>;
}

I'm struggling coming up with a DataFromDataProvider type that compiles without me passing in TData explicitly as a second parameter, which I don't think I can do.

Any help would be greatly appreciated.

4
  • If the number of providers is fixed and relatively small (<= 10), best thing I can come up with is by changing getDataFromProviders to use variadic arguments, use phantom type to encode the key ("ten", "greet"). Lots of boilerplate but it gives what you want: gist.github.com/evansb/7afc5ac7e640a06759276f456970e857 Commented Jun 5, 2017 at 8:12
  • Thanks @EvanSebastian, that's an interesting approach. I didn't know you could write [K in K2] where K2 is something other than an array. The resulting type is exactly what I want, but the code doesn't work. This approach loses the physical names of data providers. I could make each provider return their names but I want to keep it DRY. Commented Jun 5, 2017 at 12:00
  • Oh, I just copy pasted your code, you need to modify it obviously. I should have left the implementation blank, my bad. Yeah it's not possible to make each provider return their type and get a string literal type from it, unfortunately. I believe you need existential type for that Commented Jun 5, 2017 at 12:37
  • No worries. I was thinking about the [K1 in K2] bit more and realised K2 is not an array but a union of string values, and a single string could be considered a union with just one one value in the set. Makes more sense now. Commented Jun 5, 2017 at 21:33

1 Answer 1

8
+100

Imagine that you have a type that maps provider name to the data type returned by the provider. Something like this:

interface TValues {
    ten: number;
    greet: string;
}

Note that you don't actually have to define this type, just imagine it exists, and use it as generic parameter, named TValues, everywhere:

interface DataProvider<TData> {
    getData(): TData;
}

type DataProviders<TValues> = 
    {[name in keyof TValues]: DataProvider<TValues[name]>};


function getDataFromProviders<TValues>(
    providers: DataProviders<TValues>): TValues {

    const result = {};

    for (const name of Object.getOwnPropertyNames(providers)) {
        result[name] = providers[name].getData();
    }

    return result as TValues;
}


const values = getDataFromProviders({
    ten: { getData: () => 10 },
    greet: { getData: () => 'hi' }
});

magically (in fact, using inference from mapped types, as @Aris2World pointed out), typescript is able to infer correct types:

let n: number = values.ten;
let s: string = values.greet;

update: as pointed out by the author of the question, getDataFromProviders in the code above does not really check that each property of the object it receives conforms to DataProvider interface.

For example, if getData is misspelled, there is no error, just empty object type is inferred as return type of getDataFromProviders (so you will still get an error when you try to access the result, however).

const values = getDataFromProviders({ ten: { getDatam: () => 10 } });

//no error, "const values: {}" is inferred for values

There is a way to make typescript to detect this error earlier, at the expense of additional complexity in DataProviders type definition:

type DataProviders<TValues> = 
    {[name in keyof TValues]: DataProvider<TValues[name]>}
   & { [name: string]: DataProvider<{}> };

The intersection with indexable type adds a requirement that every property of DataProviders must be compatible with DataProvider<{}>. It uses empty object type {} as generic argument for DataProvider because DataProvider has the nice property that for any data type T, DataProvider<T> is compatible with DataProvider<{}> - T is the return type of getData(), and any type is compatible with empty object type {}.

Sign up to request clarification or add additional context in comments.

9 Comments

That is a really neat solution!
I consider this a good advanced example of mapped types. I think that your post will be perfect if you could substitute 'magically' with an explanation or a reference to the documentation ;)
Type inference in the absence of generics is relatively well documented in the handbook. Unfortunately there is only one short paragraph about type inference for generics here, and there is no mention about its limits. The magic is in getting what you want while staying within undocumented limits - if you push compiler too hard it starts inferring empty object type {} or even worse, any, so --noImplicitAny is a must if you rely on type inference.
Thanks @artem, nice work. There was only one imperfection I noticed - the compiler doesn't seem to tightly enforce consumers of getDataFromProviders to pass parameters in the form of { [name: string]: DataProvider<any> }. So you can pass in a spelling mistake e.g. { ten: { getDatam: () => 10 } } and it compiles, only erroring if you try to use the result like result.ten because TS thinks the result is now {}. If the compiler would error when making the call with the wrong params it would be perfect!
There is a way to detect the spelling mistakes earlier, not sure if it worth the additional complexity however. I updated the answer.
|

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.