Home > Blockchain >  Typescript conditionally make certain properties optional
Typescript conditionally make certain properties optional

Time:12-08

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 type U, then we recursively evaluate UnwrapOptions<U> (if you might be nesting Options), and return it in a union with undefined. This undefined is probably only needed if the Option is top-level. That is, UnwrapOptions<Option<A>> should be UnwrapOptions<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 be string, 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 not Option assignable, we just do a straight mapped type where UnwrapOptions is applied to each property. For those which are Option 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 argument U, 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.


Playground link to code

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?

  • Related