1
interface SubProps1<T> {
    foo: boolean
    bar: string
    baz: T
}

interface SubProps2<T> {
    fooz: boolean
    barr: string
    bazd: Array<T>
}

interface Props<T> {
    sub1: SubProps1<T>
    sub2: SubProps2<T>
}

type CSSObjectWithLabel = Record<string, unknown>

type StylesConfig<T> = {
    [K in keyof Props<T>]?: StylesConfigFunction<Props<T>[K]>;
}

type StylesConfigFunction<SubProps> =
    (base: CSSObjectWithLabel, props: SubProps) => CSSObjectWithLabel;

function mergeStyles<T>(
    nonColorStyles: StylesConfig<T>,
    colorStyles: StylesConfig<T>,
    ): StylesConfig<T> {

type S = StylesConfig<T>

const result: S = {};

const uniqueKeys = new Set([
    ...Object.keys(nonColorStyles),
    ...Object.keys(colorStyles)
] as (keyof S)[])

function assigner<K extends keyof S>(key: K) {
    result[key] = (base: CSSObjectWithLabel, props: Props<T>[K]): CSSObjectWithLabel => ({
        ...base,
        ...colorStyles[key]?.(base, props),
        ...nonColorStyles[key]?.(base, props)
    })
}

return result;

}

We try to merge into result object different functions with signature in accordance to the key value Type map defined by Props interface.

We face an issue where Props<T>[K] (inside assigner function, on the line we assign result[key] is resolved as the following union SubProps1 | SubProps2. Please refer to joined screenshot.

I understand it's not possible for TS to know the value of key in the assigner function, but its possible to say that the type of result[key] for a given specific key correspond to the specific mapped StylesConfigFunction, hence allowing only the correct SubProps type and not the union of both types.

This looks like a TS limitation, but maybe this limitation is motivated by a reason I'm not aware of.

Is there any reason for this limitation? is it done on purposes? If not, is it possible to make TS smart enough for handling this case? Thanks a lot

  • Hi, The correct type (imposed to me by react-select is what I initially wrote as StylesConfig2. (renamed as StylesConfig now )). – Wilfried Sugniaux Jan 05 '23 at 16:24
  • I'd say the issue is described at [ms/TS#30581](https://github.com/microsoft/TypeScript/issues/30581) and the recommended fix is to make `StylesConfig` a *distributive object type* as described in [ms/TS#47109](https://github.com/microsoft/TypeScript/pull/47109), and then use that type for result as shown in [this Playground link](https://tsplay.dev/WkOZlW). Does that meet your needs? If so I'll write up an answer explaining more fully; if not, what am I missing? – jcalz Jan 05 '23 at 16:28
  • I think your playground link solution should work. I need to verify that on my actual code because of the following point: I can use the custom distributive object type for result object, but it needs to be compatible with the type I have on my code example, because its imposed to me by a third party library. – Wilfried Sugniaux Jan 05 '23 at 16:45
  • So should I write up an answer or am I waiting for you to check your actual code and then [edit] the question with additional requirements? – jcalz Jan 05 '23 at 16:55
  • You can write the answer. If the type from the lib prevent me to solve the issue, I will forward your solution to React-select team through a github issue – Wilfried Sugniaux Jan 05 '23 at 17:09
  • Okay I will do so when I get a chance. – jcalz Jan 05 '23 at 17:17

1 Answers1

1

When you try to assign to result[key], the compiler complains because it doesn't see the correlation between the types StylesConfig<T>[K] and (base: CSSObjectWithLabel, props: Props<T>[K])=>CSSObjectWithLabel.

Since microsoft/TypeScript#30769 was introduced with TypeScript 3.5, assignments to index access types have been checked strictly in a way that results in the error you're getting. Right now what's happening is roughly that since StylesConfig<T>[K] is not seen as identical to (base: CSSObjectWithLabel, props: Props<T>[K])=>CSSObjectWithLabel, the compiler widens K to its constraint which is the union type "sub1" | "sub2". Because it doesn't know which one of those key is, it decides to be conservative and only allow assignments if the assigned value would satisfy both possibilities at the same time. That turns the union into an intersection. If you had a value of type StylesConfig<T>["sub1"] & StylesConfig<T>["sub2"], then you could safely assign it to result[key] no mater what key was. And since you're not doing such an assignment, the compiler warns you that it can't be sure what you're doing is safe. After all, if K is widened to "sub1" | "sub2", then the value you're assigning is of type StylesConfig<T>["sub1"] | StylesConfig<T>["sub2"], which is the union and not the intersection. Oh well.


This is fundamentally the same problem as described in microsoft/TypeScript#30581; when you're trying to compare two union or union-constrained types which are correlated because they both depend on a single value (like key), the compiler doesn't do a full counterfactual analysis on each actual possibility for that value. Instead it loses track of the correlation and complains.

As such, the recommended fix is described at microsoft/TypeScript#47109, which is to make sure that the two types you compare are seen as identical to each other. The method is to turn StylesConfig<T> into a object type that takes another type argument corresponding to the particular set of keys you're looking at (you can make it default to the full set):

type StylesConfig<T, K extends keyof Props<T> = keyof Props<T>> = {
  [P in K]?: (
    base: CSSObjectWithLabel,
    props: Props<T>[P]
  ) => CSSObjectWithLabel;
}

And then make sure that both sides of the assignment are seen as type StylesConfig<T, K>[K]:

function assigner<K extends keyof S>(key: K) {
  const r: StylesConfig<T, K> = result;
  r[key] = (base: CSSObjectWithLabel, props: Props<T>[K]): CSSObjectWithLabel => ({
    ...base,
    ...colorStyles[key]?.(base, props),
    ...nonColorStyles[key]?.(base, props)
  })
}

I've widened result (of type StylesConfig<T>) to r (of type StylesConfig<T, K>) which works because it's just looking at the K property. And then r[key] is seen to be of type StylesConfig<T>[K]. Then, because you are just indexing into a mapped type with a generic key, the compiler is able to see that type as ( base: CSSObjectWithLabel, props: Props<T>[K]) => CSSObjectWithLabel;, which is identical to the type of the value you assign.

And now it compiles with no error.


This sort of refactoring feels more like an art than a science to me, even though I'm fairly familiar with the method described in microsoft/TypeScript#47109. After all, StylesConfig<T>[K] and StylesConfig<T, K>[K] are the same types already; why aren't they "identical" according to the compiler? I don't have a great answer for that and maybe someone could file a feature request so that this sort of change wouldn't be necessary. For now, though, this is how I would proceed.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360