0

I am attempting to construct a generic mapped type that accepts 2 type arguments and returns another object with the intersection of their keys while excluding any properties that have no type overlap. In other words, I would like this generic type SharedProperties<A, B> to pass the following test:

type A = { x: string; y?: string };
type B = { x: number; y?: string; z: string };
type C = { y: string };
type D = { y: string | number | boolean };

type AB = SharedProperties<A, B> // => { y?: string }
type BC = SharedProperties<B, C> // => { y: string }
type CD = SharedProperties<C, D> // => { y: string }

My initial attempt was as follows:

type SharedProperties<A, B> = OmitNever<
  {
    [K in keyof A & keyof B]: A[K] extends B[K]
      ? A[K]
      : B[K] extends A[K]
      ? B[K]
      : never;
  }
>;

where OmitNever is a type that simply removes properties whose type is never.

However, the above approach didn't cover my use-case because I noticed that the ? and readonly property modifiers were being lost upon evaluation of the mapped type. I did some further digging and found out that the property modifiers are only preserved if your mapped type is homomorphic. See this answer.

It seems the easiest way for the compiler to infer that a mapped type is homomorphic is for the mapped key to have form [K in keyof SOMETHING]. Thus, I modified the above to look like this:

type SharedProperties<A, B> = OmitNever<
  {
    [K in keyof (A & B)]: A[K] extends B[K]
      ? A[K]
      : B[K] extends A[K]
      ? B[K]
      : never;
  }
>;

I assumed this would work, since if there was any arbitrary key K' that existed in A but not in B, the conditional types should resolve to never and just get filtered out.

This is where I ran into my second problem: if K didn't exist on A, then for whatever reason, A[K] would resolve to any instead of never. This means the conditional type would never resolve to never, and I was just getting an object similar to A & B.

Finally, I was forced add an ugly as clause to the mapped type keys:

type SharedProperties<A, B> = OmitNever<
  {
    [K in keyof (A & B) as K extends keyof A ? (K extends keyof B ? K : never) : never]: A[K] extends B[K]
      ? A[K]
      : B[K] extends A[K]
      ? B[K]
      : never;
  }
>;

My problem with the above generic is that, although it works, the readability is greatly hindered because I'm basically repeating myself here. I only want to include properties that are shared between two objects. As I see it, the root of this problem is that if a key K does not exist in some object A, A[K] has type any instead of type never, which totally breaks my conditional types.

Is there a compiler option to force unknown object properties to resolve to never instead of any? I have the --strict flag enabled, and while it throws a type error, it still resolves to any.

Playground link

dlq
  • 2,743
  • 1
  • 12
  • 12
  • "where `OmitNever` is a type that simply removes properties whose type is `never`." Could you either remove that or give us the definition? A [mcve] is very helpful to those who want to answer. Especially with no link to the TS playground (which is up for me, so ‍♂️ why it's not for you) – jcalz Jul 19 '21 at 22:23
  • I'm also confused about what you want to come out when objects share a property name but the property is optional/readonly on just one of the objects. Also also, your `SharedProperties` definition seems like it does invalid things: if `K` extends `keyof (A & B)` then there is no guarantee that `A[K]` exists, so any use of `A[K]` isn't just `any`, it's a compiler error. If you want to suppress then you need to come up with some other check (e.g., `K extends keyof (A | B)` will guarantee that `A[K]` exists) or you will need to do some further checking. – jcalz Jul 19 '21 at 22:28
  • Maybe [this](https://tsplay.dev/WolJjw) is what you're looking for? Without some more details about what should happen to the edge cases where properties have different modifiers, or where the property types have overlap but neither extends the other (like `{a: string}` and `{b: number}` have overlap at `{a: string; b: number}`). – jcalz Jul 19 '21 at 22:37
  • @jcalz Your `OmitNever` implementation was identical to mine. I've updated the answer to include a playground link. I also just noticed my generic throws a compiler error, which is exactly what you said: `K cannot be be used to index A`. Sorry I wasn't able to point that out; for whatever reason my VSCode instance doesn't show me `tsc` compiler errors in declaration files. – dlq Jul 20 '21 at 04:12
  • For properties that have some overlap, I would want to give preference to the more "assignable" type, i.e. the type of the property that extends the other. This is exactly how I tried to define the conditional type of the mapped properties in the examples above. The property modifiers should be preserved if two properties are exactly identical (e.g. `SharedProperties<{x?:string},{x?:string}>`, but can have arbitrary behavior otherwise. – dlq Jul 20 '21 at 04:18
  • "For properties that have some overlap, I would want to give preference to the more general type, i.e. the type of the property that extends the other." But when two types overlap it does not mean that one is a subtype of the other. A value like `{a: "", b: 0}` is both of type `{a: string}` and of type `{b: number}`. So the types `{a: string}` and `{b: number}` do have overlap: namely `{a: string} & {b: number}`, equivalent to `{a: string, b: number}`. But *neither of the constituent types extend the other*. What do you want to see there? Only the intersection makes sense to me. – jcalz Jul 20 '21 at 04:24
  • You are correct... the two types you described do have overlap, and the type that represents that overlap is the intersection between the two. I believe I have taken the wrong approach attempting to construct `SharedProperties` as purely a mapped type, when it really would have been more prudent to `Pick` from the intersection of the type arguments. Thank you jcalz, I learned a good amount from this exercise. – dlq Jul 20 '21 at 04:31

0 Answers0