Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Closed
Griffork opened this issue Mar 16, 2018 · 19 comments
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug

Comments

@Griffork
Copy link

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.

@Jessidhia
Copy link

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.

@RyanCavanaugh RyanCavanaugh added the Needs Investigation This issue needs a team member to investigate its status. label Mar 16, 2018
@ahejlsberg
Copy link
Member

ahejlsberg commented Mar 16, 2018

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

@Griffork
Copy link
Author

Griffork commented Mar 19, 2018

@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.

@ahejlsberg
Copy link
Member

ahejlsberg commented Mar 19, 2018

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.

@ahejlsberg ahejlsberg added Working as Intended The behavior described is the intended behavior; this is not a bug and removed Needs Investigation This issue needs a team member to investigate its status. labels Mar 19, 2018
@Griffork
Copy link
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?

@Griffork
Copy link
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?

@RyanCavanaugh
Copy link
Member

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

@Griffork
Copy link
Author

@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.

@ahejlsberg
Copy link
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.

@Griffork
Copy link
Author

Griffork commented Mar 20, 2018

@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.

@ahejlsberg
Copy link
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>>>);

@Griffork
Copy link
Author

Griffork commented Mar 21, 2018

@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.

@Griffork
Copy link
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.

@ahejlsberg
Copy link
Member

ahejlsberg commented Mar 21, 2018

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.

@Griffork
Copy link
Author

Griffork commented Mar 21, 2018

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]
};

@Griffork
Copy link
Author

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

@ahejlsberg
Copy link
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.

@Griffork
Copy link
Author

Griffork commented Mar 23, 2018

@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).

@typescript-bot
Copy link
Collaborator

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

@microsoft microsoft locked and limited conversation to collaborators Jul 25, 2018
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug
Projects
None yet
Development

No branches or pull requests

5 participants