3

I'm trying to extract a generic type that is a union of the types of objects present in an array passed within the argument of a function. It's easiest to illustrate this with code, so given the following:

type Extension = {
  name: string,
  fields: {
    [key: string]: string
  }
}

type Config = {
  name: string,
  extensions: Extension[]
}

const ext1: Extension = {
  name: 'ext1',
  fields: {
    field1: 'test',
    field2: 'test'
  }
}

const ext2: Extension = {
  name: 'ext1',
  fields: {
    field3: 'test',
    field4: 'test'
  }
}

const config: Config = {
  name: 'Test',
  extensions: [ext1, ext2]
}

// Ignore the obviously wrong typings here
function create(config: Config): T { ... }

How can I ensure that T has the following type:

type CombinedFields = {
  field1: string,
  field2: string,
} & {
  field3: string,
  field4: string
}
Jonny Asmar
  • 1,900
  • 14
  • 16
  • 1
    Is it strictly required to declare `ext1` and `ext2` to be of type `Extension`? It looks like `field1`, `field2`, and so on only exist in the values `ext1` and `ext2`, but never in any types, and these values are already declared to be of type `Extension`, so there probably is no further type inference from the compiler either; to give the compiler a fighting chance, can this typing on `ext1` and `ext2` be dropped? – Christian Fuchs Mar 12 '23 at 02:24
  • Yes -- in my actual use-case the types of the extensions varies and is further inferred through deeper generics down the chain. I think the side-effect is ultimately similar to what you're suggesting. – Jonny Asmar Mar 12 '23 at 02:47
  • 1
    Does [this approach](https://tsplay.dev/NnlRxW) meet your needs? If so I could write up an answer explaining; if not, what am I missing? – jcalz Mar 12 '23 at 03:15
  • Holy cow @jcalz that works! :D I had to modify quite a bit to shim it into my actual implementation, but works like a charm. You rock -- thank you!! – Jonny Asmar Mar 12 '23 at 03:34
  • Oops I have to go to bed so I hope to get back to this tomorrow (I forgot that the semiannual clock-fiddling is removing an hour from this weekend) – jcalz Mar 12 '23 at 04:08
  • oh... fiddlesticks. guess I'm going to bed after 3am tonight no worries; I'll keep an eye on this thread so I can approve your answer whenever you get it up. thanks again! – Jonny Asmar Mar 12 '23 at 06:51

1 Answers1

1

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

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Perfect answer and excellent explanation! I had some fun implementing this cause in my actual use-cases, extensions are classes, but with some handy usage of InstanceTypes, I was able to get it to work great. Curious: is there any way to move the as cost satisfies operator to a helper function (e.g. createConfig)? I tried, but had no luck; seems the nested types are lost in the pipeline. Not a big deal cause I have boilerplate gen scripts, but ultimately I'd like for it to be a little more foolproof if possible. – Jonny Asmar Mar 13 '23 at 04:08
  • @JonnyAsmar Use TS 5.0's `const` generics: https://tsplay.dev/mp08xm – kelsny Apr 05 '23 at 01:39