Skip to content

Type simultaneously does and does not have property #30657

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

Closed
jwalton opened this issue Mar 30, 2019 · 3 comments · Fixed by #30753
Closed

Type simultaneously does and does not have property #30657

jwalton opened this issue Mar 30, 2019 · 3 comments · Fixed by #30753
Assignees
Labels
Bug A bug in TypeScript

Comments

@jwalton
Copy link

jwalton commented Mar 30, 2019

TypeScript Version: 3.4.1 and 3.2.2

Code

interface TextChannel {
    id: string;
    type: 'text';
    phoneNumber: string;
}

interface EmailChannel {
    id: string;
    type: 'email';
    addres: string;
}

type Channel = TextChannel | EmailChannel;

export type ChannelType = Channel extends { type: infer R } ? R : never; // 'text' | 'email'

// Stolen from lodash.
type Omit<T, K extends keyof T> = Pick<
    T,
    ({ [P in keyof T]: P } & { [P in K]: never } & { [x: string]: never })[keyof T]
>;

type ChannelOfType<T extends ChannelType, A = Channel> = A extends { type: T }
    ? A
    : never;

/**
 * A NewChannel is a Channel with a 'type', a 'localChannelId', with no id,
 * and every other field is optional.
 */
export type NewChannel<T extends Channel> = Pick<T, 'type'> &
    Partial<Omit<T, 'type' | 'id'>> & { localChannelId: string };

/**
 * Create a NewChannel of the specified type, ready to hand off to, for example,
 * a react form which can fill it in with values and turn it into a real Channel.
 */
export function makeNewChannel<T extends ChannelType>(type: T): NewChannel<ChannelOfType<T>> {
    const localChannelId = `blahblahblah`;
    return { type, localChannelId };
}

// Here's the exciting bit:

const newTextChannel = makeNewChannel('text');
// Property 'phoneNumber' does not exist on type 'NewChannel<TextChannel>'. ts(2339)
newTextChannel.phoneNumber = '613-555-1234';

const newTextChannel2 : NewChannel<TextChannel> = makeNewChannel('text');
// But this works!
newTextChannel2.phoneNumber = '613-555-1234';

Expected behavior:

Since newTextChannel and newTextChannel2 are both of type NewChannel<TextChannel>, I'd expect them both to have a phoneNumber property (or both to not have a phoneNumber property).

Actual behavior:

newTextChannel does not have this property, and newTextChannel2 does, even though these two seem to be of the same type!

Playground Link: link

Related Issues: No

@jwalton
Copy link
Author

jwalton commented Mar 30, 2019

It even works if you do:

const newTextChannel3 : NewChannel<ChannelOfType<'text'>> = makeNewChannel('text');
newTextChannel3.phoneNumber = '613-555-1234';

@ahejlsberg
Copy link
Member

There's definitely something odd going on here. It appears related to the ChannelOfType type. For example, it works if you change ChannelOfType to:

type ChannelOfType<T extends ChannelType> =
  T extends 'text' ? TextChannel :
  T extends 'email' ? EmailChannel :
  never;

@ahejlsberg ahejlsberg added the Bug A bug in TypeScript label Mar 30, 2019
@weswigham
Copy link
Member

weswigham commented Apr 4, 2019

OK, so this is only strange because we have two completely different types which instantiate to the same alias symbol and alias type arguments; under the hood we actually are talking about two completely separate types. NewChannel<ChannelOfType<T>> instantiates to NewChannel<TextChannel> alias-wise, however NewChannel<ChannelOfType<T>> is really NewChannel<ChannelOfType<T, TextChannel> | ChannelOfType<T, EmailChannel>> if you stop printing ChannelOfType's alias, which is in turn

type NewChannel<ChannelOfType<T, TextChannel> | ChannelOfType<T, EmailChannel>> = 
  & Pick<ChannelOfType<T, TextChannel> | ChannelOfType<T, EmailChannel>, "type">
  & Partial<Pick<ChannelOfType<T, TextChannel> | ChannelOfType<T, EmailChannel>, (
    | ({ [P in keyof ChannelOfType<T, TextChannel>]: P; } & { type: never; id: never; } & { [x: string]: never; })
    | ({ [P in keyof ChannelOfType<T, EmailChannel>]: P; } & { type: never; id: never; } & { [x: string]: never; })
  )[keyof ChannelOfType<T, TextChannel> & keyof ChannelOfType<T, EmailChannel>]>>
  & { localChannelId: string; }

if you stop printing NewChannel's alias. When T="text", a bunch of stuff reduces like so:

type NewChannel<TextChannel> = 
  & Pick<TextChannel, "type">
  & Partial<Pick<TextChannel, (
    | ({ [P in keyof TextChannel]: P; } & { type: never; id: never; } & { [x: string]: never; })
    | ({ [P in keyof never]: P; } & { type: never; id: never; } & { [x: string]: never; })
  )[keyof TextChannel & never]>>
  & { localChannelId: string; }

OK, now what's gone wrong should be obvious - keyof ChannelOfType<T, TextChannel> & keyof ChannelOfType<T, EmailChannel> instantiates to never, causing us to index that Pick by never and thus pull out no keys. The root cause is keyof never reducing to never - it should be string | number | symbol (the keyof-specific unknown direction bound). This makes sense; since never is the subtype of all types, it must have all keys.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Bug A bug in TypeScript
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants