In order for this to be possible at all, you need the compiler to keep track of the particular keys and values of ext1
, ext2
, and config
. But if you annotate those variables with wide types like Extension
and Config
, you will throw away the information you need to keep track of. So you can't annotate. Instead, you can use the satisfies
operator to check that the variables are assignable to those types without widening to 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;
};
} */
Okay, those types know about the field names. For config
we need to go a little further; it's important for extensions
to keep track of the length and order of its elements, so it can distinguish between [ext1, ext2]
and [Math.random()<0.5 ? ext1 : ext2]
(or at least I'm assuming we need to distinguish between those). That means we should use a const
assertion on config
and allow Config
's extensions
property to be a readonly
array type because that's what const
assertions give you:
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;
};
}];
} */
Okay, now that's enough information to proceed.
Here's one approach:
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 takes a tuple of Extension
-assignable elements and converts it to an intersection of the fields
properties of those elements. The technique is very similar to that described in the answer to TypeScript merge generic array. In general, the idea is to map the things we want to intersect into a contravariant type position (in this case, the parameter of a function), and then infer a single type for the union of those (inferring from the parameter position of a union of functions results in an intersection of those parameter types, as described in the release notes for conditional types).
Let's test it:
const ret = create(config);
/* const ret: {
readonly field1: "test";
readonly field2: "test";
} & {
readonly field3: "test";
readonly field4: "test";
} */
Looks good!
Playground link to code