Home > Enterprise >  Inferring types from constant object
Inferring types from constant object

Time:08-07

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.

Playground link to code

  • Related