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

Object.keys: Return Non-widening literal types for type-inferred objects while maintaining existing behavior for type-assigned objects #60483

Closed
nakjun12 opened this issue Nov 12, 2024 · 3 comments
Labels
Unactionable There isn't something we can do with this issue

Comments

@nakjun12
Copy link

nakjun12 commented Nov 12, 2024

⚙ Compilation target

ES2015

⚙ Library

dom, es2015, es2016, es2017, ESNext

Missing / Incorrect Definition

Hello, I'd like to discuss Object.keys.
I know there have been many discussions about this topic.
First, I want to clarify that this is not about keyof.

I want to discuss objects with inferred types rather than explicitly assigned types.

interface Dimensions {
    width: number;
    height: number;
    depth?: number;
}

const p = {
    width: 32, 
    height: 14,
    depht: 11 // <-- fine
};
console.log(p.depht); // Accessible due to structural typing
const q: Dimensions = p; // Type checking allows this

The above case shows type assignment.

const p = {
    width: 32,
    height: 14,
    depht: 11
}
/**
{
    width: number;
    height: number;
    depht: number;
}
*/

In cases where the type is inferred like above, we can avoid issues with incorrect keys due to structural typing.
In such cases, I believe we should return Non-widening literal types.
As many have mentioned before, I don't want to discuss keyof.
Here's the type I've designed:

 keys<T extends string>(o: { [P in T]: any }): T[];
 keys(o: {}): string[];

This enhancement would:

  1. Preserve existing behavior for explicitly typed objects (returning string[])
  2. Return precise literal types for inferred object types

For example:

let p = {
  width: 32,
  height: 14,
  depth: 11,
};
/**
 {
    width: number;
    height: number;
    depth: number;
}
*/

const pKeys = Object.keys(p);// ("width"| "height" | "depth")[]

This approach follows a similar pattern to Object.values:

values<T>(o: { [s: string]: T; } | ArrayLike<T>): T[];
values(o: {}): any[];

Also, the reason for using extends string is that Object always converts keys to strings, excluding symbols. This design decision aligns with Object.keys' behavior of not returning symbols.

I propose that objects created through type inference should return Non-widening literal types as demonstrated above. This change would preserve the exact key information available at compile time.

Thank you for considering my proposal regarding Object.keys and Non-widening literal types. This enhancement aims to improve TypeScript's type inference precision while maintaining backwards compatibility with existing code.

I welcome any feedback or suggestions from the TypeScript team and community members. Please let me know if you need any clarification or if there are specific aspects of this proposal that warrant further discussion.

Best regards,

Sample Code

declare interface ObjectConstructor
  extends Omit<ObjectConstructor, 'keys' | 'entries'> {
  keys<T extends string>(o: { [P in T]: any }): T[];
  keys(obj: {}): string[];
}
declare var Object: ObjectConstructor;

let p = {
  width: 32,
  height: 14,
  depth: 11,
};
/**
 {
    width: number;
    height: number;
    depth: number;
}
*/

const pKeys = Object.keys(p);// ("width"| "height" | "depth")[]

Documentation Link

Referenced specification and implementation:

I extensively researched issues related to Object.keys. While these discussions are different from my current proposal, they provided valuable context and helped shape my understanding:

Related discussions and issues I've studied:

@jcalz
Copy link
Contributor

jcalz commented Nov 12, 2024

I don't understand how this is supposed to work without something like exact types. Using your proposed example, the following compiles but has a runtime error:

interface Foo {
  a: string;
  b: string;
  c: string;
}
function acceptFoo(foo: Foo) {
  for (const key of Object.keys(foo)) {
    foo[key].toUpperCase(); // apparently this is okay, but it shouldn't be
  }
}

const foo = { a: "abc", b: "def", c: "ghi", d: 123 };
acceptFoo(foo); // 💥 RUNTIME ERROR

Playground link to code

There's no way today to write typings that will distinguish "type-inferred" object types from "type-assigned" object types. I'm also not sure why this doesn't have to do with keyof. You didn't write keyof in your example code but TS will infer a keyof type when you call your Object.keys(). What am I missing here?

@RyanCavanaugh RyanCavanaugh added the Unactionable There isn't something we can do with this issue label Nov 12, 2024
@RyanCavanaugh
Copy link
Member

I'm not seeing any actual change in the language you're proposing here that makes this work. There's still this problem

let p = {
  width: 32,
  height: 14,
  depth: 11,
};
function fn(x: typeof p) {
  // Alleged: this cannot contain 'foo'
  const keys = Object.keys(x);
}
const obj = { ...p, foo: 32 }
fn(obj); // But it doess

@nakjun12
Copy link
Author

Thank you for sharing your insights about Object.keys() runtime issues.

I completely understand your points, but I was hoping you could help clarify something I'm a bit confused about.
I noticed that while Object.values() would face similar runtime error concerns, it seems to allow for more narrow types.
I'm curious about the reasoning behind this design decision - would you mind explaining this aspect?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Unactionable There isn't something we can do with this issue
Projects
None yet
Development

No branches or pull requests

3 participants