For this scenario to work, the compiler must keep track of specific keys and values for ext1
, ext2
, and config
. However, if you use broad types like Extension
and Config
as annotations on those variables, you will lose the necessary information for tracking them. Therefore, avoiding annotations is crucial. Instead, utilize the satisfies
operator to validate that the variables are assignable to those types without widening them:
const ext1 = {
name: 'ext1',
fields: {
field1: 'test',
field2: 'test'
}
} satisfies Extension;
/* const ext1: {
name: string;
fields: {
field1: string;
field2: string;
};
} */
const ext2 = {
name: 'ext1',
fields: {
field3: 'test',
field4: 'test'
}
} satisfies Extension;
/* const ext2: {
name: string;
fields: {
field3: string;
field4: string;
};
} */
These types now have knowledge about the field names. When it comes to config
, we need to take additional steps; it's essential for extensions
to maintain the length and order of its elements, distinguishing between [ext1, ext2]
and
[Math.random()<0.5 ? ext1 : ext2]
(or at least I assume this distinction is required). This means applying a
const
assertion on
config
and allowing
Config
's
extensions
property to be a
readonly
array type due to the benefits of
const
assertions:
type Config = {
name: string,
extensions: readonly Extension[]
}
const config = {
name: 'Test',
extensions: [ext1, ext2]
} as const satisfies Config;
/* const config: {
readonly name: "Test";
readonly extensions: readonly [{
name: string;
fields: {
field1: string;
field2: string;
};
}, {
name: string;
fields: {
field3: string;
field4: string;
};
}];
} */
With this information in place, we can proceed with confidence.
One possible solution is outlined below:
type ExtensionsToIntersection<T extends readonly Extension[]> =
{ [I in keyof T]: (x: T[I]["fields"]) => void }[number] extends
(x: infer I) => void ? I : never;
declare function create<T extends Config>(config: T):
ExtensionsToIntersection<T["extensions"]>;
The
ExtensionsToIntersection<T>
type transforms a
tuple containing elements assignable to
Extension
into an
intersection of the
fields
properties of those elements. The concept aligns closely with the approach detailed in response to TypeScript merge generic array. Essentially, by mapping the elements to intersect within a contravariant type position and inferring a single type from the union of those mappings, we achieve the desired intersection of parameter types.
Let's put this solution to the test:
const ret = create(config);
/* const ret: {
readonly field1: "test";
readonly field2: "test";
} & {
readonly field3: "test";
readonly field4: "test";
} */
Everything appears to be functioning correctly!
Playground link to code