Reputation: 55
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
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"
Upvotes: 2