CodeAndCats
CodeAndCats

Reputation: 7965

TypeScript Generics

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.

Upvotes: 4

Views: 3726

Answers (1)

artem
artem

Reputation: 51629

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 {}.

Upvotes: 8

Related Questions