Reputation: 146
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
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
.
Upvotes: 1