Home > Back-end >  How do I create a TypeScript type with dynamic properties based on another property?
How do I create a TypeScript type with dynamic properties based on another property?

Time:02-23

I'd like to create a TypeScript definition that uses the strings provided in headers to be the names of properties nested in that type, as below:

obj = {
  headers: ['first', 'second', 'third'],
  first: [{
    id: 'someStrA',
    second: [{
      id: 'someStrB',
      third: [{
        id: 'someStrC'
      }],
    }],
  }]
}

This object will be used in an n-dimensional view, where n is the length of headers, but n can vary based on what exactly is being presented.

CodePudding user response:

There is no specific Obj type in TypeScript that corresponds to your set of desired values. The constraint between the members of the headers property and the keys of the other properties cannot be expressed directly. However, you can create a generic Obj<H> type where the H type parameter corresponds to the tuple of string literal types from the headers property. If you know H is, for example, ["first", "second", "third"], then Obj<["first", "second", "third"]> would be a specific type that acts as you want. Here's how Obj<H> might look:

type Obj<H extends string[]> = { headers: H } & ObjProps<H>

type ObjProps<H> = H extends [infer H0, ...infer HR] ? {
    [P in Extract<H0, string>]: ({ id: string } & ObjProps<HR>)[]
} : unknown

So an object of type Obj<H> has a headers property of type H, and it is also (via intersection) a value of type ObjProps<H>, which takes care of the nesting.

The ObjProps<H> type uses variadic tuple types to split the H type into its first element H0 and the rest of the elements HR. Then ObjProps<H> has a property whose key is H0 (that's the mapped type {[P in Extract<H0, string>]: ...}) and whose value is an array of object types with a string-valued id property which is also an ObjProps<HR> (meaning we recurse down). If H is the empty tuple then ObjProps<H> is the unknown type which is our base case.

Let's just make sure this looks like what we want:

type ConcreteObj = Obj<["first", "second", "third"]>;
/* type ConcreteObj = {
    headers: ["first", "second", "third"];
} & {
    first: ({
        id: string;
    } & {
        second: ({
            id: string;
        } & {
            third: {
                id: string;
            }[];
        })[];
    })[];
} */

eh, that's a little ugly; let's collapse those intersections:

type Expand<T> = T extends object ? { [K in keyof T]: Expand<T[K]> } : T;
type ExpandedConcrete = Expand<ConcreteObj>;
/* type ExpandedConcrete = {
    headers: ["first", "second", "third"];
    first: {
        id: string;
        second: {
            id: string;
            third: {
                id: string;
            }[];
        }[];
    }[];
} */

Looks good!


Of course you probably don't want to have to annotate obj with the Obj<["first", "second", "third"]> type. You'd probably like the compiler to infer ["first", "second", "third"] from obj. You can get this to happen with the aid of a generic helper function:

const asObj = <H extends string[]>(obj: Obj<[...H]>) => obj;

And then instead of annotating obj you just infer its type from the result of asObj():

const obj = asObj({
    headers: ['first', 'second', 'third'],
    first: [{
        id: 'someStrA',
        second: [{
            id: 'someStrB',
            third: [{
                id: 'someStrC'
            }],
        }],
    }]
});
// const obj: Obj<["first", "second", "third"]>

This helper function also catches mistakes, since it requires that the object passed in is a valid Obj<H> for the H inferred from the headers property:

const badObj = asObj({
    headers: ['first', 'second', 'third', 'fourth'],
    first: [{
        id: 'someStrA',
        second: [{
            id: 'someStrB',
            third: [{
                id: 'someStrC' // error!  Property 'fourth' is missing 
            }],
        }],
    }]
});

Note that by making Obj<H> generic it might force you to add generic type parameters to other places in your code, or do other workarounds to prevent that. But that's out of scope for the question as asked (and would take a long time to go over here) so I'll skip it.

Playground link to code

  • Related