I'm currently trying to make a utility type to unwrap sniptt monads Options. Here's my code so far:
export interface Option<T> {
type: symbol;
isSome(): boolean;
isNone(): boolean;
match<U>(fn: Match<T, U>): U;
map<U>(fn: (val: T) => U): Option<U>;
andThen<U>(fn: (val: T) => Option<U>): Option<U>;
or<U>(optb: Option<U>): Option<T | U>;
and<U>(optb: Option<U>): Option<U>;
unwrapOr(def: T): T;
unwrap(): T | never;
}
export type UnwrappedOptionsType<T> = T extends (infer U)[]
? UnwrappedOptionsType<U>[]
: T extends object
? {
[P in keyof T]: T[P] extends Option<infer R>
? UnwrappedOptionsType<R> | undefined
: UnwrappedOptionsType<T[P]>;
}
: T;
What I expect to happen is that types get inferred and properties that are Options are optional. Suppose I have the following type:
type SignUpRequest = {
username: string;
password: string;
email: Option<string>;
}
When I use UnwrappedOptionsType<SignUpRequest>
, I expect to get the following type:
{
username: string;
password: string;
email?: string | undefined;
}
What I get instead:
{
username: string;
password: string;
email: string;
}
It's able to successfully infer the type of the option, but it never makes it so that it also accepts undefined
. How do I make the options optional?
Edit: Changed code to make example reproducible. Also, I specifically want the properties to be optional, not just possibly undefined.
CodePudding user response:
Here's one possible approach:
type UnwrapOptions<T> =
T extends Option<infer U> ? UnwrapOptions<U> | undefined :
T extends readonly any[] ? {[I in keyof T]: UnwrapOptions<T[I]>} :
T extends object ? (
{ [K in keyof T as Option<any> extends T[K] ? never : K]: UnwrapOptions<T[K]> } &
{ [K in keyof T as Option<any> extends T[K] ? K : never]?: UnwrapOptions<T[K]> }
) extends infer U ? { [K in keyof U]: U[K] } : never :
T;
This is a recursive conditional type, and as such, there are bound to be lots of edge cases where it behaves in a surprising or undesirable way. So before you use this, or something else, you should make sure to do lots of testing.
Okay, let's examine it. If you evaluate UnwrapOptions<T>
where T
is...
...
Option<U>
for some typeU
, then we recursively evaluateUnwrapOptions<U>
(if you might be nestingOption
s), and return it in a union withundefined
. Thisundefined
is probably only needed if theOption
is top-level. That is,UnwrapOptions<Option<A>>
should beUnwrapOptions<A> | undefined
;...an array or tuple type, then we do a simple mapped array/tuple type where
UnwrapOptions
is applied to each numeric element. That is,UnwrapOptions<[A, B]>
should be[UnwrapOptions<A>, UnwrapOptions<B>]
;...a primitive type, then we return that primitive type. That is,
UnwrapOptions<string>
should bestring
, etc;...a non-array object type, then we need to split the object into its
Option
-assignable properties and its non-Option
-assignable properties. For those which are notOption
assignable, we just do a straight mapped type whereUnwrapOptions
is applied to each property. For those which areOption
assignable, we do the same thing, but use the?
mapping modifier to make all the properties optional. We then have to join these two halves back together with an intersection. That's enough to give the types you want, but intersections can be ugly; since an intersection of object types like{a: 0} & {b: 1}
is equivalent to a single object type like{a: 0; b: 1}
, I use conditional type inference to copy the intersection into a new type argumentU
, and then do a no-op mapped type to join the intersection into one type.
Okay, let's test it.
type SignUpRequest = {
username: string;
password: string;
email: Option<string>;
}
type UnwrappedSignupRequest = UnwrapOptions<SignUpRequest>;
/* type UnwrappedSignupRequest = {
username: string;
password: string;
email?: string | undefined;
} */
That's what you wanted. What if we take a more complicated type?
interface Foo {
a: string;
b?: number;
c: string[];
d: { z?: string };
e: Option<number>;
f: Option<string>[];
g: Option<string> | number;
h: [1, Option<2>, 3];
i: { y: Option<string> };
j: Option<{ x: Option<{ w: string }> }>;
k: Foo;
}
That becomes
type UnwrappedFoo = UnwrapOptions<Foo>;
/* type UnwrappedFoo = {
a: string;
b?: number | undefined;
c: string[];
d: {
z?: string | undefined;
};
f: (string | undefined)[];
h: [1, 2 | undefined, 3];
i: {
y?: string | undefined;
};
k: any; // <-- this displays as any but it is not
e?: number | undefined;
g?: string | number | undefined;
j?: {
x?: {
w: string;
} | undefined;
} | undefined;
} */
which is reasonable, I think. The only confusing bit is that IntelliSense displays the recursive part (the k
property) as any
. But it actually does keep track of that type, which you can see if you inspect a property:
declare const uFoo: UnwrappedFoo;
uFoo.k;
/* (property) k: {
a: string;
b?: number | undefined;
c: string[];
d: {
z?: string | undefined;
};
f: (string | undefined)[];
h: [1, 2 | undefined, 3];
i: {
y?: string | undefined;
};
k: any;
e?: number | undefined;
g?: string | number | undefined;
j?: {
x?: {
w: string;
} | undefined;
} | undefined;
} */
So there you go, looks good to me, I think. But you should test it and update accordingly.
CodePudding user response:
You will need to add an additional type guard to your UnwrappedOptionsType type to make the option optional:
type UnwrappedOptionsType<T> = T extends (infer U)[]
? UnwrappedOptionsType<U>[]
: T extends object
? {
[P in keyof T]: T[P] extends Option<infer R>
? (UnwrappedOptionsType<R> | undefined) | undefined
: UnwrappedOptionsType<T[P]>;
}
: T;
With the addition of the type guard, the UnwrappedOptionsType for the SignUpRequest type will be:
type UnwrappedOptionsType<SignUpRequest> = {
username: string;
password: string;
email?: (string | undefined) | undefined;
}
The output will look like this:
{
username: string;
password: string;
email?: string | undefined;
}
Is that what you're going after?