Gabriel Braz
Gabriel Braz

Reputation: 55

How to get type inference of a nested object?

I would like to get type inference of dynamics objects. I searched through the site but didn't find exactly what i was looking for. I am sorry if it is repeated.

Basically I want to have some global constants objects of the same shape, so that I switch which is active but still being able to have the same properties inside, but only with different values.

const example = {
    constant1: {
        value1: 'value1',
        value2: 'value2',
        value3: 'value3',
    },
    constant2: {
        value1: 'other value1',
        value2: 'other value2',
        value3: 'other value3',
    },
    constant3: {
        value1: 'another value1',
        value2: 'another value2',
        value3: 'another value3',
    },
};

So the example object can have any property, but they have to be equal, if I change one of the values inside one of the constants, I need it to throw an type error, but if I change in everyone of them, it's fine.

This is how I tried at first, it seemed to work but not how i would like it.

interface TypeTest<T> {
    [key: string]: T;
}

const CreateTest = <T>(obj: TypeTest<T>) => obj;

const test = CreateTest({
    some: {
        other: 'test',
    },
    thing: {
        other: 'testing',
    },
});

type constants = keyof typeof test;

As interfaces don't get type inference, I am using a creator function that receives the object and returns it typed. But it don't type the inner objects, as typescript infer a union type of each inner object. Also I don't get any type of the outer object, as type constants = string | number

To work around this, I tried it this way:

type TypeTest<D, T> = {
    [key in keyof D]: T;
};

const CreateTest = <D, T>(obj: TypeTest<D, T>) => obj;

const test = CreateTest({
    some: {
        other: 'test',
    },
    thing: {
        other: 'testing',
    },
});

type constants = keyof typeof test;

Now I get the right type of the outer object(type constants = "some" | "thing"), but completely lost type inference of the inner object, as now I only the unknown type, even if if I use [key2 in keyof T]: string as the shape of the inner object, it still is unknown.

I was able to work around all this, but in the end the developer experience is not that great.

interface TypeTest<T extends readonly string[], U extends readonly string[]> {
    outer: T;
    inner: U;
    values: {
        [Property1 in T[number]]: {
            [Property2 in U[number]]: string;
        };
    };
}

const CreateTest
= <
    T extends readonly string[],
    U extends readonly string[],
> (constants: TypeTest<T, U>) => constants.values;

const test = CreateTest({
    outer: [ 'some', 'thing' ] as const,
    inner: [ 'other' ] as const,
    values: {
        some: {
            other: 'test',
        },
        thing: {
            other: 'testing',
        },
    },
});

type constants = keyof typeof test;
type values = keyof typeof test[constants];

Now I pass two arrays that are only used to get the types and thrown away after, also both arrays have to be asserted as const, otherwise I only get string[] type out of them.

So, is there a way to pass an object to the CreateTest() function, and get the typed value out of it? With type constants = "some" | "thing" and type values = "other

EDIT:

The level of nesting is only 2 levels deep. But is it possible to get more of it?

Also is it possible to get different level of nesting? Like one object having 3 levels of nesting while other having 5.

I won't be needing it that way, I am just curious.

Upvotes: 2

Views: 2477

Answers (1)

jcalz
jcalz

Reputation: 330456

One approach is to write a createTest() helper function which takes a single parameter obj, of a generic type T where we constrain T to an object type in which all the properties are the same type, and specifically the intersection of the properties from T.

When you tried to constrain all properties to the same type, the compiler inferred a union of all the properties. This is often desirable, since it is as permissive as possible; a value of type {a: A, b: B} will always be assignable to the type {a: A | B, b: A | B}. But you only want to accept things where A and B already agree with each other. By switching the constraint to {a: A & B, b: A & B} it will enforce that.

Anyway, we can implement this as a recursive constraint

const createTest = <T extends IntersectProps<T>>(obj: T) => obj;

where IntersectProps<T> is defined as

type IntersectProps<T> =
  { [K in keyof T]: (x: T[K]) => void }[keyof T] extends
  (x: infer I) => void ? Record<keyof T, I> : never;

It works similarly to the union-to-intersection code from this question; we walk through each property, put it in a contravariant position, and then infer from it. The other question explains it more. But you can see for yourself that this type does intersect properties:

interface A { x: 1 };
interface B { y: 2 };
type Example = IntersectProps<{ a: A, b: B }>;
/* type Example = {
    a: A & B;
    b: A & B;
} */

Okay, so let's try it:

const badTest = createTest({
  a: { b: 1, c: "", d: true },
  // ------> ~ <--- error
  e: { b: 2, c: 3, d: false }
  // ------> ~ <--- error
})

That fails because the intersection type {b: number, c: string, d: boolean} & {b: number, c: number, d: boolean} is equivalent to {b: number, c: never, d: boolean}, and the c property in each of the a and e properties cannot be assigned to never. You can fix it by changing one of the c properties to be the same type as the other:

const goodTest = createTest({
  a: { b: 1, c: "", d: true },
  e: { b: 2, c: "z", d: false }
}) // okay

So now you can get the behavior you want without adding redundant information in the arguments to createTest():

const test = createTest({
  some: {
    other: 'test',
  },
  thing: {
    other: "okay",
  },
});

type Constants = keyof typeof test;
// type Constants = "some" | "thing"

Playground link to code

Upvotes: 2

Related Questions