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

Omit loses type information when used with index signature #45367

Closed
TrevorBurnham opened this issue Aug 8, 2021 · 7 comments
Closed

Omit loses type information when used with index signature #45367

TrevorBurnham opened this issue Aug 8, 2021 · 7 comments
Labels
Duplicate An existing issue was already created

Comments

@TrevorBurnham
Copy link

Bug Report

πŸ”Ž Search Terms

Omit, index signature

πŸ•— Version & Regression Information

This bug is present in all versions currently available in the Playground that support Omit, from 3.5.1 to 4.4.0-beta.

⏯ Playground Link

Playground link with relevant code

πŸ’» Code

type NamedPlayer = {
  name: string;
  extraLives: number;
  [key: string]: unknown;
}

type Player = Omit<NamedPlayer, 'name'>;

function giveLifeToPlayer(p: Player) {
  p.extraLives = p.extraLives + 1; // Error, TypeScript thinks extraLives is of type unknown.
}

πŸ™ Actual behavior

TypeScript raises the error Object is of type 'unknown' when adding + 1 to p.extraLives because it sees that field as having type unknown.

πŸ™‚ Expected behavior

The type generated by Omit<NamedPlayer, 'name'> should have the same type information as NamedPlayer for all fields other than name.

In other words, Omit<NamedPlayer, 'name'> should yield the same type as Omit<NamedPlayer, 'name'> & Pick<NamedPlayer, 'extraLives'>:

type PlayerAlt = Omit<NamedPlayer, 'name'> & Pick<NamedPlayer, 'extraLives'>;

function giveLifeToPlayer2(p: PlayerAlt) {
  p.extraLives = p.extraLives + 1; // No error, TypeScript recognizes that extraLives is a number.
}
@MartinJohns
Copy link
Contributor

See the existing issues: #43139, #40999, etc. Omit<T> is using keyof T, and keyof T for types with indexers always results in string | number, so your individual properties are unavailable. This is working as intended, see #42436.

@TrevorBurnham
Copy link
Author

Thanks for the quick response and the discussion links! That helps me understand the problem more deeply: Omit is implemented as

type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>

and keyof NamedPlayer resolves to string | number. So any specific key names are lost at the keyof step.

Though, keyof isn't really the problemβ€”I'd get the same result as I get with Omit even if I directly specified the keys I want the in Pick:

Pick<NamedPlayer, "extraLives" | string | number>

The compiler treats "extraLives" | string | number as equivalent to string | number, so the type information for the extraLives field is still lost.

So we're left with a situation where I need to do two separate Picks to get the type I want:

Pick<NamedPlayer, string | number> & Pick<NamedPlayer, "extraLives">

I can see why this is a hard problem! I'm not so sure that it needs to be considered WAD, though. Is it crazy to suggest that Pick<T, string | number> should yield the same result as Pick<T, string | number> & Pick<T, "foo" | "bar"> when type T declares fields foo and bar in addition to an indexer? I see that a change to Pick that would do exactly that is under discussion: #41383

Note that the change to Pick proposed there would simply make Omit<T, "foo"> a no-op for types with indexers… which isn't quite the behavior I'd want to see (namely, dropping the type information for "foo"), but is an improvement over the current behavior IMO, and could allow for further Omit improvements. Playground

@MartinJohns
Copy link
Contributor

This is not a bug, this is working as intended. A change to omit has been proposed and rejected already. There are implementations out there (even in the issues linked) that work as you want by first excluding the Index signature.

@TrevorBurnham
Copy link
Author

Sorry, I realize I'm bringing up something that's been brought up before, but I'm looking through the linked issues and struggling to find what you're describing. Where was a change to Omit proposed, and why was it rejected?

@RyanCavanaugh RyanCavanaugh added the Duplicate An existing issue was already created label Aug 10, 2021
@RyanCavanaugh
Copy link
Member

The problem is that a mapped type doesn't "know" what's it's doing from a higher level; all it sees is a collection of keys and some transformation to do to those keys. Since we (humans) made a type alias and named it Omit, we know that it's a function for removing properties, and since we (humans) made a type alias and named it Pick, we know that it's a function for picking individual properties. But all the mapped type is doing is operating over the set of keys of the object, and this object includes the unbounded keyset string, so the resultant type has to have the key string, with its associated type. This is consistent with its most straightforward interpretation, albeit maybe not exactly what you expected to happen given the higher-level operation you think is being implied by these human-named Omit and Pick aliases.

@TrevorBurnham
Copy link
Author

But all the mapped type is doing is operating over the set of keys of the object, and this object includes the unbounded keyset string, so the resultant type has to have the key string, with its associated type.

That's true, but I'm not sure how that explains why Omit necessarily needs to lead to the behavior on this ticket. Returning to an example:

type NamedPlayer = {
  name: string;
  extraLives: number;
  [key: string | number]: unknown;
}

// Current behavior of Omit<NamedPlayer, 'name'>
type OmitResult1 = {
  [key: string | number]: unknown;
}

// Suggested behavior of Omit<NamedPlayer, 'name'>
type OmitResult2 = {
  extraLives: number;
  [key: string | number]: unknown;
}

As far as I can tell, an Omit utility that fits your description could return either OmitResult1 or OmitResult2. The only difference is that the current Omit implementation removes types associated with individual keys when an unbounded keyset exists. I understand why that's the case, but I don't understand why it's necessarily the case.

In terms of implementation: Since keyof T loses information when T has an indexer, is it conceivable for Omit to be based on some abstraction other than keyof? Since types are allowed to contain both an indexer and individual keys, I'd imagine that an operator that preserves all of that information would be very useful. A hand-wavy hypothetical:

keyof NamedPlayer; // string | number
propertyof NamedPlayer; // 'name' | 'extraLives' | Indexer<string | number>

@typescript-bot
Copy link
Collaborator

This issue has been marked as a 'Duplicate' and has seen no recent activity. It has been automatically closed for house-keeping purposes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Duplicate An existing issue was already created
Projects
None yet
Development

No branches or pull requests

4 participants