Skip to content

Nested conditional generic types yields boolean not assignable to true | false | undefined. #22630

Description

@Griffork

TypeScript Version: 2.8RC

Search Terms:

Code

image

End of friday, so I don't have enough time to make a proper repro, hopefully this will be enough to go by:

    type RecursivePartial<T> = { [param in keyof T]?: T[param] extends object ? RecursivePartial<T[param]> : T[param] };

    interface UpdateValue<T> {
        $set: T extends object? RecursivePartial<T>: T;
    }

    interface ArrayUpdate<T> {
        $wildcard: WriteQuery<T>;
    }
    export type TypeWriteQuery<T> =
        T extends Array<infer U>
        ?
            Partial<ArrayUpdate<U>>
        :
            T extends object
            ?
                WriteQuery<T>
            :
                Partial<UpdateValue<T>>
        ;

    export type WriteQuery<T> = { [param in keyof T]?: TypeWriteQuery<T[param]> };
///----- Erroring code:
interface Approved {
    approved: boolean;
}
var check = <WriteQuery<Approved>>{ approved: { $set: true } };

Expected behavior:
Works fine (unless I've made another mistake).

Actual behavior:
Errors that boolean isn't assignable to true | false | undefined because boolean isn't assignable to false.

Activity

  1. Jessidhia commented on Mar 16, 2018

    @Jessidhia

    I've gotten a similar error but with an object instead.

    interface Stamp {
      id: number
      imageUrl: string
      /** Hide from the list of new stamps. */
      hidden: boolean
    }
    type StampOptions = Pick<Stamp, 'hidden'>
    
    const defaultOptions: StampOptions = {
      hidden: false
    }
    
    // The | StampOptions is something similar to React.Component#setState from @types/react
    declare function makeStamp<Keys extends keyof StampOptions> (id: number, options?: Pick<StampOptions, Keys>|StampOptions): Stamp
    
    function makeStampSeries<Keys extends keyof StampOptions> (seriesId: number, options?: Pick<StampOptions, Keys>|StampOptions, count = 10): ReadonlyArray<Stamp> {
      return Array.from({ length: count }, (_, i) => makeStamp(seriesId * 100 + i + 1, options))
    }
    Argument of type 'Pick<Stamp, "hidden"> | Pick<Pick<Stamp, "hidden">, Keys> | undefined' is not assignable to parameter of type 'Pick<Stamp, "hidden"> | Pick<Pick<Stamp, "hidden">, "hidden"> | undefined'.
      Type 'Pick<Pick<Stamp, "hidden">, Keys>' is not assignable to type 'Pick<Stamp, "hidden"> | Pick<Pick<Stamp, "hidden">, "hidden"> | undefined'.
        Type 'Pick<Pick<Stamp, "hidden">, Keys>' is not assignable to type 'Pick<Pick<Stamp, "hidden">, "hidden">'.
          Property 'hidden' is missing in type 'Pick<Pick<Stamp, "hidden">, Keys>'.
    

    This Pick trickery is used to get, effectively, the same effect as Partial, but without allowing random undefined values.

  2. ahejlsberg commented on Mar 16, 2018

    @ahejlsberg
    Member

    Griffork I'm not seeing any errors with your example.

  3. Griffork commented on Mar 19, 2018

    @Griffork
    Author

    Anders Hejlsberg (@ahejlsberg) My apologies, in my rush I missed one important part of the code, here's the updated sample:

    type RecursivePartial<T> = { [param in keyof T]?: T[param] extends object ? RecursivePartial<T[param]> : T[param] };
    
    interface UpdateValue<T> {
        $set: T extends object? RecursivePartial<T>: T;
    }
    interface ArrayUpdate<T> {
        $wildcard: WriteQuery<T>;
    }
    export type TypeWriteQuery<T> =
        T extends Array<infer U>
        ?
            Partial<ArrayUpdate<U>>
        :
            T extends object
            ?
                WriteQuery<T>
            :
                Partial<UpdateValue<T>>
        ;
    
    export type WriteQuery<T> = { [param in keyof T]?: TypeWriteQuery<T[param]> };
    
    
    export async function Send<T>(Url: string, write?: WriteQuery<T>, options?: any): Promise<T[]> {
        return [];
    }
    
    ///----- Erroring code:
    interface Approved {
        approved: boolean;
    }
    
    Send<Approved>("doot",
    { approved: { $set: <boolean>true } });

    It needed the object literal to be passed into the function call for it to work.

    Edit: Casting the object literal directly to the type expected by the function <WriteQuery<Approved>> works, so I'll use that for now.

  4. ahejlsberg commented on Mar 19, 2018

    @ahejlsberg
    Member

    Best I can tell this is working as intended. The issue here is that the conditional type in TypeWriteQuery<T> is distributive (see definition in #21316). Thus, when T is a union type (such as boolean), the conditional type is applied to each of the constituents of the union type (i.e. true and false). There is more discussion on this topic in #22596.

  5. added
    Working as IntendedThe behavior described is the intended behavior; this is not a bug
    and removed
    Needs InvestigationThis issue needs a team member to investigate its status.
    on Mar 19, 2018
  6. Griffork commented on Mar 19, 2018

    @Griffork
    Author

    It's intended that it errors when the type is enforced by a function but not when its cast to or assigned to that type?

  7. Griffork commented on Mar 19, 2018

    @Griffork
    Author

    Argh, can't edit post on mobile.
    It only worked above because of the cast.

    And it's working as intended that any other type works fine, but booleans can't be passed to functions without a cast?

  8. RyanCavanaugh commented on Mar 20, 2018

    @RyanCavanaugh
    Member

    Griffork the linked issue demonstrates how to cause a conditional type to not distribute over unions

  9. Griffork commented on Mar 20, 2018

    @Griffork
    Author

    Ryan Cavanaugh (@RyanCavanaugh) I've read through the issue twice now and don't understand how anything in there solves my problem.

    What I have read is this:

    The fact that the unit type boolean has a finite number of values shouldn't lead to magically different behavior compared to its infinitely-domained counterparts. This is especially confusing because the user never wrote a union type in the above example.

    Which seems to imply my above example is a bug, since every atomic type but boolean works this way.

    Also I don't understand why boolean has to expand to true|false. If I wanted true|false then I'd write true|false.

  10. ahejlsberg commented on Mar 20, 2018

    @ahejlsberg
    Member

    Griffork The boolean type is exactly equal to true | false. There is zero difference between the two.

    The issue you're seeing isn't specific to boolean. Any union of literal types will have the same issues. For example, using a type ApprovalStatus = "yes" | "no" instead of boolean also wouldn't work in your example. In fact, your code only works with string and number because they happen to be infinite types that (by necessity) are treated differently than union types.

    As I mentioned above, you can use the techniques in #22596 to control whether and how the TypeWriteQuery<T> distributes over union types.

  11. Griffork commented on Mar 20, 2018

    @Griffork
    Author

    Anders Hejlsberg (@ahejlsberg) apparently I'm missing something obvious, but when reading through #22596 I didn't see any "techniques" that solve my problem. Is it too much for me to ask for you to provide an example?

    I suppose I understand what you're saying that boolean should be equal to a union of literal types, but that's not what I expect to happen when I write the word boolean (as opposed to true|false). When I write boolean I expect it to behave equally to the other built-in atomic types, since it is a built-in atomic type (the fact that it happens to have only two possible values is irrelevant I feel).

    The other thing that feels inconsistent is that (at least in strict mode) boolean behaves differently to everything else because it's the only keyword that implies a union type.

    Edit: fixed typos.

  12. ahejlsberg commented on Mar 21, 2018

    @ahejlsberg
    Member

    Griffork I'd change TypeWriteQuery<T> to something like:

    type TypeWriteQueryDistributed<T> =
        T extends Array<infer U> ? Partial<ArrayUpdate<U>> :
        T extends object ? WriteQuery<T> :
        T extends null | undefined ? T :
        never;
    
    type Primitives<T> = T extends object | null | undefined ? never : T;
    
    export type TypeWriteQuery<T> =
        TypeWriteQueryDistributed<T> |
        (Primitives<T> extends never ? never : Partial<UpdateValue<Primitives<T>>>);
  13. Griffork commented on Mar 21, 2018

    @Griffork
    Author

    Anders Hejlsberg (@ahejlsberg) ok that seems to work (I have another error which I suspect is unrelated).

    How does that work though? I don't understand why wrapping primitives in another layer of genericity helps.

  14. Griffork commented on Mar 21, 2018

    @Griffork
    Author

    Ok, the other error was indeed unrelated.

    The other thing I've just noticed, which is what you were trying to tell me all along, is that fixing the boolean problem will also fix problems I would have had with trying to wrap an object that defines string or number literals, or any other union as value types, because we want them all to be treated as a group.

    Still don't understand why this works though.

  15. ahejlsberg commented on Mar 21, 2018

    @ahejlsberg
    Member

    The TypeWriteQueryDistributed<T> and Primitives<T> types are both distributive: When T is a union type they are evaluated individually for each constituent in the union and then the results are unioned back together.

    Say you're resolving TypeWriteQuery<string[] | string | boolean>.

    TypeWriteQueryDistributed<string[] | string | boolean> is the same as TWQD<string[]> | TWQD<string> | TWQD<true> | TWQD<false> (because the conditional type in TWQD is distributive). That in turn becomes Partial<ArrayUpdate<string>> | never | never | never which is just Partial<ArrayUpdate<string>>.

    Similarly Primitives<string[] | string | boolean> is the same as P<string[]> | P<string> | P<true> | P<false>. That in turn becomes never | string | true | false, which is just string | boolean. This type doesn't extend never (i.e. it isn't an empty union).

    So, you end up with Partial<ArrayUpdate<string>> | Partial<UpdateValue<string | boolean>>. Which presumably is what you were looking for. But I'm not really sure what your scenario is.

  16. Griffork commented on Mar 21, 2018

    @Griffork
    Author

    My scenario is that I can have any JSON object stored in a database, and the database has very a specific query structure. Because we don't have typing information at runtime I'm trying to get compile-time checks for query objects.
    This means that the following object:

    interface MyFoo {
        a: string;
        b: boolean;
        c: {
            d: boolean;
            e: string;
            x: string[];
            y: boolean[];
            z: {
                i: boolean;
                j: string;
            }[];
        };
        f: <boolean[];
        g: string[]
        h: {
            i: boolean;
            j: string;
        }[];
    }

    Needs to become:

    interface MyFooQuery {
        a?: UpdateValue<string>;
        b?: UpdateValue<boolean>;
        c?: UpdateValue<{
                d: boolean;
                e: string;
                x: string[];
                y: boolean[];
                z: {
                    i: boolean;
                    j: string;
                }[];
            }> | 
            { //And so-on recursively
                d?: UpdateValue<boolean>;
                e?: UpdateValue<string>;
                x?: ArrayUpdate<string>;
                y?: ArrayUpdate<boolean>;
                z?: ArrayUpdate<{
                    i: boolean;
                    j: string;
                }>;
            };
        f?: ArrayUpdate<boolean>;
        g?: ArrayUpdate<string>;
        h?: ArrayUpdate<{
            i: boolean;
            j: string;
        }>;
    }

    Naturally, I don't want to have to manually type every single interface, since there's a lot of them.

    Potential problem area:
    Assuming the definition of ArrayUpdate is actually this:

    interface ArrayUpdate<T> {
        $set: T[];
    }

    The thing is, that ArrayUpdate<string | boolean> is not the same as ArrayUpdate<string> | ArrayUpdate<boolean> because the first one allows me to do this:

    var test: ArrayUpdate<string | number> = {
        $set: ["toot", 3]
    }

    And the other one only permits this:

    var test2: ArrayUpdate<string>| ArrayUpdate<number> = {
        $set: ["toot", "anothertoot"]
    };
    //or
    var test3: ArrayUpdate<string>| ArrayUpdate<number> = {
        $set: [2, 3]
    };

    but this errors:

    var test4: ArrayUpdate<string>| ArrayUpdate<number> = {
        $set: ["toot", 3]
    };
  17. Griffork commented on Mar 23, 2018

    @Griffork
    Author

    Anders Hejlsberg (@ahejlsberg) does that clear my use-case up a bit or would you like me to provide more information?

  18. ahejlsberg commented on Mar 23, 2018

    @ahejlsberg
    Member

    No, I was just saying that I couldn't be sure exactly what your intent was. Hopefully the explanation I provided above gives you a better understanding of how distributive conditional types work so you can find the solution that best works for you.

  19. Griffork commented on Mar 23, 2018

    @Griffork
    Author

    Anders Hejlsberg (@ahejlsberg) you said:

    So, you end up with Partial<ArrayUpdate<string>> | Partial<UpdateValue<string | boolean>>. Which presumably is what you were looking for. But I'm not really sure what your scenario is.

    But as explained above what I want is Partial<ArrayUpdate<string | boolean>> | Partial<UpdateValue<string | boolean>>. Is there a way to stop a type from being distributive?

    Edit: looking at the linked issue it looks like you're thinking of proposing a new syntax for this. I'm asking if there's any way I can rewrite my types to do it now, since I'd ideally like to do this at work within the month (and I can't use nightlys on a live work project).

  20. typescript-bot commented on Apr 6, 2018

    @typescript-bot
    Contributor

    Automatically closing this issue for housekeeping purposes. The issue labels indicate that it is unactionable at the moment or has already been addressed.

  21. locked and limited conversation to collaborators on Jul 25, 2018
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Working as IntendedThe behavior described is the intended behavior; this is not a bug

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions