nkr
nkr

Reputation: 3058

Making a nested structure from interfaces type-safe

I want to make a developer-defined object type-safe with generics to avoid typos in properties. The interfaces can cross-reference each other with properties, sometimes as a one-dimensional array of a specific type. null marks the last element of the nesting but this was chosen arbitrarily by me.

The structure can be nested as deep as the developer wants and should only allow properties of the type of the higher level property's type - but not all properties do, some are number, string, etc.

This is the basic interface structure with an example (expander):

interface Base {
    id: string;
}

interface Foo extends Base {
    name: string;
    subFoo: Foo;
    bars: Bar[];
}

interface Bar extends Base {
    description: string;
    foo: Foo;
}

const expander: Expander<Bar> = {
    foo: null, // correct
    foo: { bars: { foo: null } }, // correct
    foo: { baz: null }, // should error b/c baz is not a property of Foo
    baz: null, // should error b/c baz is not a property of Bar
};

What I have tried so far:

type BaseOrArray = Base | Base[];
type Expander<B extends BaseOrArray> = {
    [P in keyof B]: B[P] extends BaseOrArray ? Expander<B[P]> | null : never;
}[keyof B];

But this is giving me an error:

Type of property 'bars' circularly references itself in mapped type '{ [P in keyof Foo]: Foo[P] extends BaseOrArray ? Expander<Foo[P]> | null : never; }'.

Upvotes: 1

Views: 46

Answers (1)

jcalz
jcalz

Reputation: 327624

Here's one possible approach:

type Expander<T extends BaseOrArray> =
  T extends readonly Base[] ? Expander<T[number]> : {
    [K in keyof T]?: null | (T[K] extends BaseOrArray ? Expander<T[K]> : never)
  };

This is a recursive conditional type. First, it seems that you want Expander<X[]> to be equivalent to Expander<X>. So the initial conditional type just checks to see if T is an array, and if so, return Expander<T[number]> for the array element type (we index into T with number to get the array element type). If it's not an array then it's a Base-compatible object. Here we produce a mapped type so that each property at key K of T becomes an optional property (using the ? mapping modifier) whose type is essentially Expander<T[K]> | null. There's a wrinkle in that T[K] might not be a BaseOrArray, so we check it. If it is one, then we get Expander<T[K]> | null. Otherwise we just get null.

That produces the following type for Bar:

let expander: Expander<Bar>;
/* let expander: {
    description?: null | undefined;
    foo?: {
        name?: null | undefined;
        subFoo?: Expander<Foo> | null | undefined;
        bars?: Expander<Bar[]> | null | undefined;
        id?: null | undefined;
    } | null | undefined;
    id?: null | undefined;
} */

And you get the behavior you're looking for:

expander = { foo: null }; // okay
expander = { foo: { bars: { foo: null } } }; // okay
expander = { foo: { baz: null } }; // error
expander = { baz: null } // error

Looks good.


Note that I'd say the primary reason the version of Expander in the question cannot work is that you are making it an indexed access type {⋯}[keyof B] which possibly you pattern-matched from somewhere? That format looks like a distributive object type as coined in microsoft/TypeScript#47109. But you really don't want to do that, since it produces a union of all the properties, not an object type. And when you apply it to a recursive type like Foo or Bar, you'll end up with a circularity error as it needs to fully descend into the type to produce a result. Evaluation of object types can be deferred (since once the property keys are known it can wait), but indexed access types are eagerly evaluated.

Playground link to code

Upvotes: 1

Related Questions