I'm trying to write a Typescript generic type that can be used as follows:
/* All properties are nullable */
type A = {
a: string | null,
b: string | null;
}
type G_A = MyType<A>; // A | undefined
/* At least one property is not nullable */
type B = {
a: string;
b: string | null;
}
type G_B = MyType<B>; // B
So the goal of MyType<T>
is that it returns T | undefined
if all of T
's properties are allowed to be null
, but if not it should just return T
.
I'm kind of lost on how to approach this, so I don't have anything I can share that I tried.
There are different ways to go about this. One approach looks like
type MyType<T extends object> = T | ({
[K in keyof T]-?: null extends T[K] ? never : unknown
}[keyof T] extends never ? undefined : never)
Since MyType<T>
is either T
or T | undefined
, we can factor out T
and put it in a union with either the never
type (since T | never
is just T
) or undefined
.
The check we're trying to make is whether or not every single property of T
is "nullable", which I interpret as "null
can be assigned to it". That means for each property key K
, we are checking if null extends T[K]
, where the indexed access type T[K]
is the type of the property value at key K
.
I've written this as a distributive object type (as coined in microsoft/TypeScript#47109) of the form {[K in keyof T]: F<T[K]>}[keyof T]
, a mapped type into which we immediately index with all the keys, to get a union of all the properties. So {[K in keyof T]-?: null extends T[K] ? never : unknown}
will look like a new object type where each property value is either never
(if T[K]
is nullable) or unknown
(if it is not nullable), based on the conditional type. Note that never
is the bottom type in TypeScript and is absorbed into all unions (X | never
is X
), while unknown
is the top type and aborbs all unions (X | unkonwn
is unknown
). And note I used the -?
mapping modifier to turn optional properties into required ones so extra undefined
s don't show up.
Then with [keyof T]
we get the union. If all the properties of T
are nullable, then all the properties of that mapped types are never
, so the union is never
. If even one of the properties of T
is not nullable, then we have at least one unknown
in that mapped type, so the union is unknown
.
So the distributive object type is either never
(if all T
's properties are nullable) or unknown
(otherwise). And thus we check it against never
. If it matches never
, then we want to allow MyType<T>
to have undefined
in it, otherwise we don't.
So that's how it works. Let's test it:
interface A {
a: string | null,
b: string | null;
}
type G_A = MyType<A>;
// ^? type G_A = A | undefined
interface B {
a: string;
b: string | null;
}
type G_B = MyType<B>; // B
// ^? type G_B = B
That's what you wanted. Note that there could always be edge cases, such as if you have index signatures or array or tuple types, since mapping over those has special effects. You didn't ask about these so they're out of scope here, but the point is you should always make sure to thoroughly test any type build with generics and conditional types.