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.