Home > front end >  Dynamically construct a return type
Dynamically construct a return type

Time:10-07

I am creating a module where other developers can create a datamodel with (for me) unknown properties and types. Only a limited set of types is supported. The datamodel argument is an object with property names as keys and the values are config objects containing the type (as a string) and some other options. I want the function to return a typesafe object based on the datamodel with full Intellisense. I know I can use generics and make the calling code define a type and then return it, but I would not be able to accept other options in an intuitive way. Is it possible to "construct the type dynamically" based on the datamodel without generics? Or is there a smart way of using generics to actually accept an object like this but transform it to match the wanted return type?

Example code:

//Module function
function createDynamicReturnType(datamodel: { [key:string]: { type: 
   'string'|'number'|'boolean', someOption: boolean } }) : {[key:string]: string|number|boolean } {
    const typesafeModelObject = {};
    //iterate datamodel keys and map the correct type from the type string
    //'string' -> type string
    //'number' -> type number
    //'boolean' -> type boolean

    //someOption on the properties is used for something but not interesting to return

    return typesafeModelObject;
}

//Usage
const typesafePerson = createDynamicReturnType({
    name: { type: 'string', someOption: true, },
    registered: { type: 'boolean', someOption: false, },
    age: { type: 'number', someOption: true, },
});

//Wanted result
//typesafePerson has correct types and Intellisense
//typesafePerson.name -> string
//typesafePerson.registered -> boolean
//typesafePerson.age -> number

CodePudding user response:

One way to do this is to make createDynamicReturnType() generic in an object type T corresponding to the keys and type properties of datamodel. And then you can use a mapping interface to look up the desired output type for a given type. It could look like this:

interface TypeMap {
    string: string;
    number: number;
    boolean: boolean;
}

declare function createDynamicReturnType<T extends Record<keyof T, keyof TypeMap>>(
    datamodel: { [K in keyof T]: { type: T[K], someOption: boolean } }
): { [K in keyof T]: TypeMap[T[K]] };

The TypeMap interface is a mapping from the string literal types of the type properties: namely, "string", "number", and "boolean", to the corresponding output property types: namely, string, number, and boolean, respectively.

Then createDynamicReturnType() accepts datamodel of mapped type {[K in keyof T]: {type: T[K], someOption: boolean}}. The compiler is able to infer T from such a mapped type. So if you pass in, say, {foo: {type: "string", someOption: false}}, the compiler will understand that T must be {foo: "string"}. Note that T is constrained to Record<keyof T, keyof TypeMap>, meaning that it can have any keys, but its properties (T[K] for every K in keyof T) must be chosen from the keys of TypeMap (which is "string" | "number" | "boolean").

Finally, the return type is another mapped type, this type {[K in keyof T]: TypeMap[T[K]] }. The type TypeMap[T[K]] is an indexed access type, meaning that for each key K from T, we look up the property of TypeMap whose key is T[K]. For example, if T is {foo: "string"}, then when K is "foo", T[K] is "string", and TypeMap[T[K]] is TypeMap["string"] which is string, so the output type is {foo: string}.


Let's try it out:

//Usage
const typesafePerson = createDynamicReturnType({
    name: { type: 'string', someOption: true, },
    registered: { type: 'boolean', someOption: false, },
    age: { type: 'number', someOption: true, },
});

/* const typesafePerson: {
    name: string;
    registered: boolean;
    age: number;
} */

Looks good. In that call, the compiler has inferred T to be {name: "string", registered: "boolean", age: "number"}, and therefore the output type is {name: string, registered: boolean, age: number}, as desired.


There are other ways to accomplish the same goal; for example, you could make the function generic in T corresponding exactly to the datamodel parameter, and then compute the output type from it. That could look like, say, this:

declare function createDynamicReturnType<
    T extends Record<keyof T, { type: keyof TypeMap, someOption: boolean }>>(
        datamodel: T
    ): { [K in keyof T]: TypeMap[T[K]['type']] };

It's similar, but here you have to constrain T to the shape you want datamodel to have, and then you need to look up T[K]['type'] in TypeMap. It behaves the same when you call it:

const typesafePerson = createDynamicReturnType({
    name: { type: 'string', someOption: true, },
    registered: { type: 'boolean', someOption: false, },
    age: { type: 'number', someOption: true, },
});

/* const typesafePerson: {
    name: string;
    registered: boolean;
    age: number;
} */

it's just that now T is inferred to be the exact type of dataModel, with all the type and someOption properties.


The exact approach you use is up to you, and that decision possibly depends on factors outside this question.

In any case, you'll probably need a type assertion or other type safety loosening technique inside the implementation of createDynamicReturnType because it is unlikely that the compiler will be able to verify that the value you return is of type {[K in keyof T]: ...} for generic T. But again, that is outside the scope of the question as asked.

Playground link to code

  • Related