I have the following type structure
interface Setting<T> {
id: string
default: T
}
interface Section {
settings: Setting<any>[]
render: (settings: X) => string
}
I am desperately trying to write typings for the above X. In semi-pseudo-typescript-code i am imagining something like this:
type X = { [settingId: keyof settings[i]['id']]: typeof settings[i]['default'] }
The expected behavior is illustrated below:
const section: Section = {
settings: [
{
id: 'description',
default: 'This is a description'
}
],
render: (settings) => {
return `
<p >
${settings.description} <<<--- OK, type: string
${settings.foo} <<<--- FAIL, no setting with id 'foo'
</p>
`
}
}
As far as i understand it, it should be possible to infer types from constants. Any pointers would be appreciated! Thank you!
CodePudding user response:
In order to strongly type this, you want to represent both Setting and Section as being generic in the type T of the object type corresponding to the parameter to render.
And then to infer a particular T from a value of type Section<T> we need to use a helper function (so instead of const x: Section<??> = {...} you would write const x = asSection({...}). There is no way to "infer a type from a const object" the way you're asking; see Is there a way to infer a generic type without using a function for more information.
In detail:
Your Setting<T> would be a union of id/default pairs corresponding to every key in the T object type. You can write this using a distributive object type (as coined in microsoft/TypeScript#47109), which is where you make a mapped type and then immediately index into it:
type Setting<T extends object> = { [K in keyof T]-?:
{ id: K, default: T[K] }
}[keyof T];
Let's test that to make sure it works and to demonstrate what it means:
interface Foo { a: string, b: number, c: boolean }
type SettingFoo = Setting<Foo>;
/* type SettingFoo =
{ id: "a"; default: string; } |
{ id: "b"; default: number; } |
{ id: "c"; default: boolean; } */
See how Setting<Foo> is a union of the three possible id/default pairs?
Now we can write Section<T> in terms of T and Settings<T>:
interface Section<T extends object> {
settings: Setting<T>[],
render: (settings: T) => string
}
So for Foo this looks like:
type SectionFoo = Section<Foo>;
/* type SectionFoo = {
settings: Setting<Foo>[];
render: (settings: Foo) => string;
} */
Now for the helper function and inference. Unfortunately the simplest version of this doesn't work:
const asSection = <T extends object>(s: Section<T>) => s;
The problem is that the compiler can't directly infer T from Settings<T>; it gets the keys, but it loses the values and falls back to any:
const section = asSection({
settings: [
{
id: 'description',
default: 'This is a description'
},
],
render: (settings) => {
return `
<p >
${settings.description.toOopsieDoodle()} <<<--- wait, no error?
${settings.foo} <<<--- FAIL, no setting with id 'foo'
</p>
`
}
})
/* const section: Section<{
description: any; // <-- not what we want
}> */
In the above we have a Section<{description: any}> when we want a Section<{description: string}> and so settings.description.toOopsieDoodle() doesn't result in an error where it should. Sure, we reject unexpected keys like settings.foo, but we want to get value types right.
Here's the way I had to do it:
const asSection = <S extends { id: K, default: any }, K extends string>(
s: {
settings: S[],
render: (settings: { [U in S as U['id']]: U['default'] }) => string;
}
): Section<{ [U in S as U['id']]: U['default'] }> => s
The idea here is to infer, not the T object type, but the S type corresponding to Settings<T>. So S should be constrained to {id: string, default: any}. Unfortunately with that constraint the compiler will infer just string for the id property values instead of the specific literal types for the key names. So I use a "dummy" generic parameter K constrained to string to give the compiler a hint that it should preserve the literal type (this is mentioned in ms/TS#10676 as one of the conditions that prevents the widening from the literal type to just string).
So now the s parameter to the function has a settings property of type S[], and the compiler will infer S for us nicely. But we still need T as the parameter to the render callback and in as the type argument in the Section<T> return type. So we have to compute T from S (which is Settings<T>). This is what the compiler couldn't do by itself, but luckily we can do it explicitly.
The type is { [U in S as U['id']]: U['default'] }. What we're doing is mapping over the union type S and remapping its keys to be the id property. For each union U member of S, the key is the id property, and the value is the default property.
Let's test it out:
const section = asSection({
settings: [
{
id: 'description',
default: 'This is a description'
},
{
id: 'pages',
default: 100
}
],
render: (settings) => {
return `
<p >
${settings.description.toUpperCase()} <<<--- OK, type: string
${settings.foo} <<<--- FAIL, no setting with id 'foo'
${settings.pages.toFixed(0)} <<<--- OK, type: number
</p>
`
}
})
/* const section: Section<{
description: string;
pages: number;
}> */
Looks good! The inferred type for section is Section<{description: string; pages: number}> (I threw another property in there so you could see it work) and the compiler knows that the settings callback parameter to render is of the type {description: string; pages: number}, so the body of that callback type checks as desired.
