Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Typescript: convert "array of generic objects" to "object of generic objects" while maintaining types

I have a library that I am re-writing to TypeScript and I am struggling with implementing proper types for one feature.

There is a function createManyWrappers that takes an array of objects as a parameter but returns object of objects.

Library's source code showing how I tried to solve this (but I failed):

export function createManyWrappers<WrapperConfigParam extends WrapperConfig<Entity>, Entity>(wrapperConfigs: WrapperConfigParam[]) {
    type Names = WrapperConfigParam['name'];

    const wrappers: { 
        [key in `${Names}Wrapper`]?: Wrapper<Entity>
    } = {};

    for (const wrapperConfig of wrapperConfigs) {
        const nameWithSuffix: `${Names}Wrapper` = `${wrapperConfig.name}Wrapper`;

        wrappers[nameWithSuffix] = createWrapper<Entity>(wrapperConfig);
    }

    return wrappers;
}

function createWrapper<Entity>(wrapperConfig: WrapperConfig<Entity>): Wrapper<Entity>  {
    return {} as Wrapper<Entity>
}

export type WrapperConfig<Entity> = {
    name: string
}

type Wrapper<Entity> = {
    name: string,
    data: Entity
    // .. other properties
}

Here is an example of using the library in some application but TS types are not working as I expected:

type Entity1 = { foo: string };
type Entity2 = { bar: number };
type Entity3 = { cor: number };

const wrapperConfigs = [
    { name: 'entity1' } as WrapperConfig<Entity1>,
    { name: 'entity2' } as WrapperConfig<Entity2>,
    { name: 'entity3' } as WrapperConfig<Entity3>
];

const wrappers = createManyWrappers(wrapperConfigs);

// entity1Wrapper, entity2Wrapper etc should be autosuggested but they are not
const { entity1Wrapper, entity2Wrapper, entity3Wrapper } = wrappers;

const data1 = entity1Wrapper.data // Type of "data1" should be "Entity1"
const data2 = entity2Wrapper.data // Type of "data2" should be "Entity1"

Do you have ideas what I can change/fix in either library's code, application code or both to make types working as expected?

  1. entity1Wrapper, entity2Wrapper etc should be properties in wrappers object
  2. type of eg. entity1Wrapper.data should be Entity1

TS playground link

like image 222
mdrobny Avatar asked Sep 08 '25 05:09

mdrobny


1 Answers

Preliminary note: generic type parameter names are conventionally given single uppercase character names like T and not long names like TypeParameter. This helps distinguish type parameters from specific types, although it does leave something to be desired when it comes to understanding. I didn't invent this convention, but I'm going to stick with it in what follows.


The biggest issue with your general approach is that TypeScript's type system is structural and not nominal; types are compared according to their structure or shape, as opposed to their name or declaration. So if you have want to have a generic type like WrapperConfig<E> depend on its type parameter E, then the shape of WrapperConfig<E> must depend on E. In your version:

type WrapperConfig<E> = {
    name: string
}

The type WrapperConfig<E> is just {name: string} no matter what E is. So WrapperConfig<Entity1> is exactly the same type as WrapperConfig<Entity2>, and you can't expect the compiler to be able to distinguish them. Just because you use the name Entity1 in WrapperConfig<Entity1>, it doesn't mean there's any relationship between them the compiler can use.

See the TypeScript FAQ entry on unused type parameters for more information.


So any reliance of WrapperConfig<E> should be structural; the easiest way to do this is to give WrapperConfig<E> a property of type E:

type WrapperConfig<E> = {
    name: string
    dummyData: E 
}

Of course you don't really want to put a value of the type into your configuration object... you really want a nominal type, where WrapperConfig<Entity1> depends on Entity1 merely by mentioning the type. To get behavior like this you can use type assertions in order to lie to the compiler about what values you've given it. Or you can make it optional and hope the compiler considers that structural enough for its type comparison algorithm:

type WrapperConfig<E> = {
    name: string
    dummyData?: E 
}

Using phantom properties like this is a way of simulating nominal types, also called "branding" or "tagging". See microsoft/TypeScript#202 for a discussion of nominal-ish typing in TypeScript.


Anyway, now we have WrapperConfig<E>. Unfortunately that's not enough information for the compiler to know what keys should be on the return type of createManyWrappers(). It's not enough for the compiler to know that it has a WrapperConfig<Entity1>, since such an object is only known to have a string-valued name property. No, you need to know the literal type of the name property. If it's "entity1", you need to know that.

So you need a more specific type than WrapperConfig<E>. Enter NamedWrapperConfig<N, E>:

interface NamedWrapperConfig<N extends string, E> extends WrapperConfig<E> {
    name: N
}

If you have a value like {name: "entity1"}, you can give it a type like NamedWrapperConfig<"entity1", Entity1> and then the compiler will know both the key name and the intended entity type. You could do this manually:

const e1Config: NamedWrapperConfig<"entity1", Entity1> = {name: "entity1"};

but that's tedious, writing out "entity1" twice like that. Another approach is to use a helper function so that N can be inferred from how you call it:

function createWrapperConfig<N extends string, E>(name: N, dummyData: E) {
    const w: NamedWrapperConfig<N, E> = { name };
    return w;
}

This function also wants some dummy data so the compiler can infer E as well as N So you could do this with a real value of that type:

const e1: Entity1 = { foo: "" }
const e1Config = createWrapperConfig('entity1', e1);

or, equivalently, just use a type assertion:

const e1Config = createWrppaerConfig('entity1', null! as Entity1);

(Note, the ideal behavior would be something like createWrapperConfig<Entity1>('entity1'), but that would require partial type parameter inference as requested in microsoft/TypeScript#26242, but such a feature is not part of the language. So that dummyData parameter is another workaround for it.)

So now we can make your wrapperConfigs object:

const wrapperConfigs = [
    createWrapperConfig('entity1', null! as Entity1),
    createWrapperConfig('entity2', null! as Entity2),
    createWrapperConfig('entity3', null! as Entity3)
];

Finally let's give typings to createManyWrappers():

function createManyWrappers<W extends NamedWrapperConfig<string, any>>(
    wrapperConfigs: readonly W[]
) {

    const wrappers: {
        [T in W as `${T extends NamedWrapperConfig<infer N, any> ? N : never}Wrapper`]?:
        T extends NamedWrapperConfig<any, infer E> ? Wrapper<E> : never;
    } = {};

    for (const wrapperConfig of wrapperConfigs) {
        const nameWithSuffix = `${wrapperConfig.name}Wrapper`;
        wrappers[nameWithSuffix as keyof typeof wrappers] = createWrapper(wrapperConfig) as any;
    }

    return wrappers;
}

I haven't changed the runtime implementation, but there are some changes to typings. First, the function has just one type parameters, W. You had W constrained to a function of another type parameter E, but E was not actually used in your call signature. The compiler would not know how to infer E from that (it doesn't have a good inference site) so it wasn't helping.

Next, in the type of wrappers, your version would lose all correlation between the name fields and the entity types in W. It would look like {entity1Wrapper: Entity1 | Entity2 | Entity3; entity2Wrapper: Entity1 | Entity2 | Entity3; entity3Wrapper: Entity1 | Entity2 | Entity3}. In order to maintain the correlation you need to iterate over each union member of W.

Let's examine how this works:

{
    [T in W as `${T extends NamedWrapperConfig<infer N, any> ? N : never}Wrapper`]?:
    T extends NamedWrapperConfig<any, infer E> ? Wrapper<E> : never;
}

The [T in W as ...] syntax is key remapping in mapped types and it lets us iterate over each union member T in W, and turn it into a key name. We could have just written T['name'] to find the name property, but I'm using conditional type inference to show how we treat T as NamedWrapperConfig<N, any> and infer N. And we append "Wrapper" to N via template literal type. For the value, we do the same thing as we did with N, but this time we pull out E.

I threw some type assertions into the implementation to suppress any warnings.


Okay, let's use it:

const wrappers = createManyWrappers(wrapperConfigs);
/* const wrappers: {
    entity1Wrapper?: Wrapper<Entity1> | undefined;
    entity2Wrapper?: Wrapper<Entity2> | undefined;
    entity3Wrapper?: Wrapper<Entity3> | undefined;
} */

That type looks as specific as you need, right?

const { entity1Wrapper, entity2Wrapper, entity3Wrapper } = wrappers;

// might be undefined after all
if (entity1Wrapper && entity2Wrapper && entity3Wrapper) {
    const data1 = entity1Wrapper.data // Entity1
    data1.foo // string
    const data2 = entity2Wrapper.data // Entity2
    data2.bar // number
    const data3 = entity3Wrapper.data // Entity3
    data3.cor // number
}

Looks good!

Playground link to code

like image 182
jcalz Avatar answered Sep 11 '25 13:09

jcalz