sparrow
sparrow

Reputation: 146

Typescript: Unexpected result of conditional test for optional properties

export type Test = {
    a: number;
    b?: number;
    c: string;
    d: number[];
    e?: number[];
};

type PropsOfType<T, V> = {
    [P in keyof T]: T[P] extends V ? P : never;
}

type aaa = PropsOfType<Test, number>;

Result:

type aaa = {
    a: "a";
    b?: undefined;
    c: never;
    d: never;
    e?: undefined;
}

Why does the conditional substitution of "P" result in the "undefined" for the "b" and "e" properties? The "e" case is even weirder since "number[]" does not extend the "number".

Upvotes: 1

Views: 122

Answers (1)

jcalz
jcalz

Reputation: 330456

Looking at PropsOfType<T, V>:

type PropsOfType<T, V> = {
  [P in keyof T]: T[P] extends V ? P : never;
}

you can see that it's a mapped type which specifically maps over the keys of T, using the syntax [P in keyof T]. While you can always map over any keylike type (such as [P in "a" | "b" | "c" | "d" | "e"]) something special happens when you map over keyof T for some type T. The mapped type is a homomorphic mapped type which preserves any readonly or optional property modifiers. Observe the difference:

type RegularMapped = { [P in "a" | "b"]: any }
/* type RegularMapped = {
    a: any;
    b: any;
} */

type HomomorphicMapped = { [P in keyof { readonly a: 0, b?: 1 }]: any }
/* type HomomorphicMapped = {
    readonly a: any;
    b?: any;
} */

Note that both RegularMapped and HomomorphicMapped are evaluated by iterating over the keys "a" and "b". Indeed "a" | "b" is exactly the same as keyof { readonly a: 0, b?: 1 }. One might therefore expect these types to be identical, but they are not, specifically because HomomorphicMapped uses the "in keyof" syntax. So while in RegularMapped, the "a" and "b" properties are neither readonly nor optional, in HomomorphicMapped, "a" is readonly and "b" is optional, copying the modifiers from {readonly a: 0, b?:1}.

That means PropsOfType<T, V> is homomorphic in the keys of T. This explains why the b and e properties are optional in the following types:

export type Test = {
  a: number;
  b?: number;
  c: string;
  d: number[];
  e?: number[];
};


type AAA = PropsOfType<Test, number>;
/* type AAA = {
    a: "a";
    b?: undefined;
    c: never;
    d: never;
    e?: undefined;
} */

Since Test has optional properties for b and e, so does AAA.


It is also important to note that, assuming you are using the --strictNullChecks compiler option and that you are not using the --exactOptionalPropertyTypes compiler option, then optional properties automatically get the undefined type added to their type (via union). And when we examine Test, we see this:

export type Test = {
  a: number;
  b?: number;
  c: string;
  d: number[];
  e?: number[];
};

/* type Test = {
    a: number;
    b?: number | undefined;
    c: string;
    d: number[];
    e?: number[] | undefined;
} */

Recall that the property type of PropsOfType<T, V> for the key P will be T[P] extends V ? P : never. When evaluating PropsOfType<Test, number> you can see that only Test["a"] extends number is true. Test["b"] is number | undefined, which does not extend number (you cannot safely assign a value of type number | undefined to a variable of type number. So "a" comes out for the a property, and never comes out for everything else. That means we should expect this:

type AAAShouldBe = {
    a: "a";
    b?: never;
    c: never;
    d: never;
    e?: never;
} 

And, despite appearances, this is what we get! Inspecting AAAShouldbe yields

/* type AAAShouldBe = {
    a: "a";
    b?: undefined;
    c: never;
    d: never;
    e?: undefined;
} */

which is exactly the same as AAA. The apparent difference is just whether or not we explicitly write | undefined for optional property types. And the never type is a special "bottom" type that gets absorbed in all unions (no values of type never exist, so if I have a value which is either a never or an undefined, then I know I have an undefined, so never | undefined is reduced to undefined).


So that's what's going on: optional properties in Test become optional properties in AAA; optional properties include undefined; and only the "a" property of Test has a type that extends number.

Playground link to code

Upvotes: 1

Related Questions