TLDR: There is no way to do this unless you pass untyped arguments and use const
and satisfies
- either on the passed arguments, or within a wrapper function.
Let's start with some examples:
// number
let case1 = 1;
// 1
const case2 = 1;
With let
the value can change, thus the type is widened to the number
primitive, since it can change, however, with the const
it can't, and because number is a primitive type we get the exact value as the type.
More examples:
// {a: string}
let case3 = {
a: 'str'
}
// {a: string}
const case4 = {
a: 'str'
}
In the case of non-primitive types like JS objects, there is no difference between the let
and const
, which I think is how Typescript is designed. When an object is defined with const
, we can't assign something else to the variable, however, we can still mutate its properties.
To prevent the compiler from widening, we somehow need to let know the compiler that this object shouldn't be mutated and this can be done using const assertion:
const languages = [
{
nicknames: {
william: 'bill',
william2: 'bill 2',
},
},
{
nicknames: {
alexander: 'sasha',
},
},
] as const
However, with the const assertion
alone, we lose type-safety and fortunately, this can be fixed by the satisfies operator, introduced in the Typescript 4.9
:
const languages = [
{
nicknames: {
william: 'bill',
william2: 'bill 2',
},
},
{
nicknames: {
alexander: 'sasha',
},
},
] as const satisfies readonly Language[]
Since const assertion
turns arrays into read-only arrays it is crucial to add readonly
when using the satisfies
operator.
Next, let's introduce some utilities that we going to use:
Prettify - Accepts a type and returns its simplified version for better readability. Transforms interface to type, simplifies intersections:
type Prettify<T> = T extends infer R ? {
[K in keyof R]: R[K]
} : never
UnionToIntersection - Turns a union to intersection:
export type UnionToIntersection<U> = (
U extends any ? (k: U) => void : never
) extends (k: infer I) => void
? I
: never;
[ValueOf](type ValueOf = T[keyof T]) - Returns a union of values of all properties of the passed type:
type ValueOf<T> = T[keyof T];
Now, let's write the function's return type. To form the response type we need to use mapped types, where we are going to map through the elements of the array, using the indexed access types. Since in the mapped type
the generic parameter will be the element of the array, which is an object, and the object can't be used as a key. Possible types for keys are defined in type PropertyKey = string | number | symbol
, thus even though we don't need the keys we will still need to change it to some primitive to make the code word. In our case, the key will be the nicknames themselves and to change the key, we need to use key remapping:
{
[K in T[number] as keyof K['nicknames']]: K['nicknames'];
}
For the following array:
[
{
nicknames: {
william: 'bill',
william2: 'bill 2',
},
},
{
nicknames: {
alexander: 'sasha',
},
},
]
The result of the mapped type
will look like this:
{
william: {
readonly william: "bill";
readonly william2: "bill 2";
};
william2: {
readonly william: "bill";
readonly william2: "bill 2";
};
alexander: {
readonly alexander: "sasha";
}
We only need the values and we can use ValueOf
for that:
ValueOf<{
[K in T[number] as keyof K['nicknames']]: K['nicknames'];
}>
This will result in a union:
{
readonly william: "bill";
readonly william2: "bill 2";
} | {
readonly alexander: "sasha";
}
Now we can use the UnionToIntersection
to turn it into a single object:
UnionToIntersection<ValueOf<{
[K in T[number] as keyof K['nicknames']]: K['nicknames'];
}>>
Now the result will be what we want:
{
readonly william: "bill";
readonly william2: "bill 2";
readonly alexander: "sasha";
}
I have actually wrapped the mapped type
with the Prettify
to get the result in the way that I've shared, without it you would get something like this:
{
readonly william: "bill";
readonly william2: "bill 2";
} & {
readonly alexander: "sasha";
}
Link to playground