1

What would be the best way of achieving this effect (note: this is not valid syntax - consider it a pseudocode):

type Config = {
  [key: string]: <T> {
    params: T,
    doSth: (params: T) => void,
  },
}

I.e. the generic T type is different for every key's value (which is an object itself), but at the same time it's being reused within that value (object) under different fields. Also, T is derived from the params field. With that I could do:

const config: Config = {
  a: {
    params: { x: 123 },
    doSth: params => {}, // Type of "params" is "{ x: 123 }"
  },
  b: {
    params: { y: 'asd' },
    doSth: params => {}, // Type of "params" is "{ y: 'asd' }"
  },
}

To my surprise, googling this doesn't yield matching results, even though it seems relatively useful pattern - there's a lot of similar problems but not really the same. Some solutions to those, that I tried to apply - all failed.

jalooc
  • 1,169
  • 13
  • 23
  • 1
    You're looking for [*existentially qualified generics*](https://en.wikipedia.org/wiki/Type_system#Existential_types), also called "existential types", but TypeScript has no direct support for them (and neither do most languages with generics). There are ways to emulate them (like [this](https://tsplay.dev/WYkj2N)) or work around them (like @TJCrowder's answer). See the answers to the linked questions for more information. – jcalz May 15 '22 at 13:03

1 Answers1

1

There are some things you can't do in TypeScript with types alone, sometimes you need a function to help even if the function doesn't actually do anything at runtime. I think this is one of those times. Here's an example of a function that gets close to what you're describing:

type ConfigEntry<T extends object> = {
    params: T;
    doSth: (params: T) => void;
};

function makeConfigEntry<T extends object>(obj: ConfigEntry<T>): ConfigEntry<T> {
    return obj;
}

Notice how ConfigEntry lets us relate the two places we'll be using that type. You'd use that function when defining the values of the properties:

const config = {
    a: makeConfigEntry({
        params: { x: 123 },
        doSth: params => {}, // Type of `params` is `{ x: number; }`
        //     ^?
    }),
    b: makeConfigEntry({
        params: { y: "asd" },
        doSth: params => {}, // Type of `params` is `{ y: string; }`
        //     ^?
    }),
};

Note there's no Config type annotation on that. TypeScript will infer it from the object initializer.

But notice the types on params aren't quite what you wanted. TypeScript won't infer the types { x: 123; } and { y: "asd"; } from your objects. It will either infer { x: number; } and { y: string; }, or if you define the objects as const, it'll infer { readonly x: 123; } and { readonly y: "asd"; }:

const config = {
    a: makeConfigEntry({
        params: { x: 123 } as const,
        doSth: params => {}, // Type of `params` is `{ readonly x: 123; }`
        //     ^?
    }),
    b: makeConfigEntry({
        params: { y: "asd" } as const,
        doSth: params => {}, // Type of `params` is `{ readonly y: "asd"; }`
        //     ^?
    }),
};

This is likely not quite what you wanted, but I think it's close, probably one of several approaches that are as close to what you want as you can get with today's TypeScript.

Playground example of the above

T.J. Crowder
  • 1,031,962
  • 187
  • 1,923
  • 1,875
  • 1
    That works - thanks. It was also one of the solutions I tried before, but frankly, as you also pointed - feels very much like a hack. Any reason this cannot be done in TS without that bogus function? – jalooc May 15 '22 at 12:18
  • 1
    @jaloc Without the function TypeScript has no incentive to attempt to infer the types. It's kind of like petals on a flower attracting pollinators. So if we only had that Config type, TypeScript wouldn't try to infer T. – kelsny May 15 '22 at 13:05
  • This can actually be done with one function and one call to that function. You might get some ideas [here](https://stackoverflow.com/a/72143555/18244921). Good luck! – kelsny May 15 '22 at 13:07
  • @jalooc - As catgirlkelly said, apparently we need it to pass the generic through. I asked one of those really knowledgeable TypeScript people (I think it was Titian Cernicova Dragomir) if he knew if there were plans for some kind of types-only thing for the situations where we need these do-nothing functions, and he said he didn't know of any -- probably because functions work, even if they feel hacky. :-) (To be clear: he isn't on the TypeScript project team, he just knows it backward and forward and is well-informed.) – T.J. Crowder May 15 '22 at 14:35
  • How would you type the `config` variable in this case? It'd be like `{ [x: string]: ConfigEntry }` but you're missing a generic on ConfigEntry? How do you solve this? – Kaspar Poland Dec 02 '22 at 18:46
  • @KasparPoland - There's no need to put a type on the `config` variable, TypeScript will infer it from the object literal. (You can see that in the playground link above. [I just added that link, it's odd I left it out originally.] Hover your mouse over `config` to see the type TypeScript infers.) But if you wanted to give it a type explicitly, it would be `{ a: ConfigEntry<{ x: number; }>; b: ConfigEntry<{ y: string; }>; }` (the type TypeScript infers). – T.J. Crowder Dec 03 '22 at 10:19
  • @KasparPoland - I don't think it's what you meant, but **if** you wanted an index signature like the one you showed, you'd have to provide the type parameter to `makeConfigEntry` explicitly in order to make the type of `x`/`y` `number|string`: https://tsplay.dev/wOxGRN But I don't think that's what you meant. – T.J. Crowder Dec 03 '22 at 10:20