How Typescript infers property types of union types?

Hodossy Szabolcs

I have the following types to support angular form typing:

import { FormArray, FormControl, FormGroup } from '@angular/forms';

export type DeepFormArray<T, U extends boolean | undefined = undefined> = T extends Array<any>
  ? never
  : T extends string | number | boolean
  ? FormArray<FormControl<T>>
  : U extends true
  ? FormArray<FormControl<T>>
  : FormArray<DeepFormGroup<T>>;

export type DeepFormGroup<T> = T extends object
  ? FormGroup<{
      [P in keyof T]: T[P] extends Array<infer K>
        ? DeepFormArray<K>
        : T[P] extends object
        ? DeepFormGroup<T[P]>
        : FormControl<T[P] | null>;
    }>
  : never;

And I would like to use it on a type, which has a string literal property functioning as a type discriminator. Consider the following example:

interface A {
    name: 'A'
}

interface B {
    name: 'B'
}

/** infered as
 * {name: 'A' | 'B'}
 */
type C = A | B;

interface D {
  prop: C[];
}

And I combine these as


// Old example, not accurate enough for my use case,
// but is solved correctly by the modifications
// suggested by @jcalz

/** infered as 
 * FormGroup<
 *   {name: FormControl<'A' | null>}
 * > | FormGroup<
 *   {name: FormControl<'B' | null>}
 * >
 */
type OldActualType = DeepFormGroup<C>;

type OldNeededType = FormGroup<{name: FormControl<'A' | 'B' | null>}>

// The correct formulation of my problem

/** inferred as
 * FormGroup<{
 *    prop: FormArray<FormGroup<{
 *        name: FormControl<"A" | null>;
 *    }>> | FormArray<FormGroup<{
 *        name: FormControl<"B" | null>;
 *    }>>;
 * }>
 */
type ActualType = DeepFormGroup<D>;
type NeededType = FormGroup<{
    prop: FormArray<FormGroup<{
        name: FormControl<"A" | "B" | null>;
    }>>;
}>

It seems to me that typescript does not create a new type for C, but treats it as two variants, and when C is used somewhere, all paths are traversed. Is there a way to modify DeepFormGroup or the ActualType definition so the infered type equals to NeededType? I am on TypeScript 4.8. Here is a TS playground url

jcalz

Often it is the desired behavior for type operations to distribute over union types. So if you have some type operation F<T>, and apply it to a union like F<A | B | C>, then often the desired behavior is that this is equivalent to the union F<A> | F<B> | F<C>. We say "F<T> distributes over unions in T".

Unfortunately, you explicitly do not want this to happen for DeepFormGroup<T> and DeepFormArray<T>, and therefore we need to modify the definitions to explicitly turn off this behavior where it occurs.


When you have a homomorphic mapped type (see What does "homomorphic mapped type" mean?) of the form {[P in keyof T]: ⋯} for generic T, the compiler distributes it over unions in T. To avoid that we need to make it no longer homomorphic. One way to do this is to "wrap" keyof T in something that doesn't change what it evaluates to, but blocks the compiler from seeing the in keyof and triggering homomorphicness (homomorphicity? homomomorphosity? homomorphàgogo?), like {[P in Extract<keyof T, unknown>: ⋯}. The Extract utility type filters unions, but unknown is the universal supertype so Extract<X, unknown> is the same as X.


Also, when you have a conditional type of the form T extends ⋯ ? ⋯ : ⋯ for generic T, the compiler distributes it over unions in T. It is therefore a distributive conditional type. To avoid that we need to make a regular non-distributive conditional type. One way to do this is to "wrap" T in something, and then "wrap" the other type in the conditional so that the check is the same. The conventional approach is [T] extends [⋯] ? ⋯ : ⋯, where both sides are wrapped in a one-tuple which doesn't change the sense of the check (arrays/tuples are considered covariant in their element types, see Why are TypeScript arrays covariant?).


That means your code becomes

export type DeepFormArray<T, U extends boolean | undefined = undefined> =
  [T] extends [Array<any>] // <-- turn off distributive conditional
  ? never
  : [T] extends [string | number | boolean] // <-- turn off distributive conditional
  ? FormArray<FormControl<T>>
  : U extends true
  ? FormArray<FormControl<T>>
  : FormArray<DeepFormGroup<T>>;

export type DeepFormGroup<T> = [T] extends [object] // <-- turn off distributive conditional
  ? FormGroup<{
    [P in Extract<keyof T, unknown>]: T[P] extends Array<infer K>
    // -> ^^^^^^^^^^^^^^^^^^^^^^^^^ <-- turn off homo-bobo-dodo-yoyo-morphitude
    ? DeepFormArray<K>
    : T[P] extends object
    ? DeepFormGroup<T[P]>
    : FormControl<T[P] | null>;
  }>
  : never; 

And the desired types come out:

interface A { name: 'A' }
interface B { name: 'B' }
type C = A | B;
interface D { prop: C[]; }
type ActualType = DeepFormGroup<D>;
/* type ActualType = FormGroup<{
    prop: FormArray<FormGroup<{
        name: FormControl<"A" | "B" | null>;
    }>>;
}> */

So that answers the question as asked. Note that it's possible you might run into a situation where you do want distributivity for some places in these types, and if so, you might need to significantly refactor things to tell the relevant use cases apart and then treat them differently. But I'll consider that out of scope here.

Playground link to code

Collected from the Internet

Please contact [email protected] to delete if infringement.

edited at
0

Comments

0 comments
Login to comment

Related