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

Generic parameter implicitly broadens object key to string. #55110

Closed
cefn opened this issue Jul 22, 2023 · 6 comments
Closed

Generic parameter implicitly broadens object key to string. #55110

cefn opened this issue Jul 22, 2023 · 6 comments
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug

Comments

@cefn
Copy link

cefn commented Jul 22, 2023

Bug Report

Const object having typed string keys should not be broadened to string when key is an inferred const function parameter and type is a const object.

In the example below SingleEntryCreated is the type at fault and it has [x:string] where it should have "iamanentry".

In the createMapWithCast factory function below, there should not be a need to cast with as Readonly<{[k in N]:V}> as it is already known to be exactly that. All three types of singleEntryCreated, singleEntryCreatedWithCast, singleEntryInline should have identical type.

🔎 Search Terms

Object key type broadened narrowed.

🕗 Version & Regression Information

⏯ Playground Link

Playground link with relevant code

💻 Code

function createMap<const N extends string, const V extends unknown>(
  entryName: N,
  value: V
) {
  return {
    [entryName]: value,
  } as const;
}

function createMapWithCast<const N extends string, const V extends unknown>(
  entryName: N,
  value: V
) {
  return {
    [entryName]: value,
  } as const as Readonly<{ [k in N]: V }>;
}


const entryName = "iamanentry";
const singleEntryCreated = createMap(entryName, 3);
const singleEntryCreatedWithCast = createMapWithCast(entryName, 3);
const singleEntryInline = {
  [entryName]: 3,
} as const;

type SingleEntryCreated = typeof singleEntryCreated;
//  ^? type SingleEntryCreated = { readonly [x:string]: 3; }
type SingleEntryCreatedWithCast = typeof singleEntryCreatedWithCast;
//  ^?  type SingleEntryCreatedWithCast =  { readonly "iamanentry": 3; }
type SingleEntryInline = typeof singleEntryInline;
//  ^?  type SingleEntryInline = { readonly "iamanentry": 3; }

🙁 Actual behavior

Embedding the construction of an object inside a factory (passing a string key for inference) has the key broadened to string.

🙂 Expected behavior

The key should not be broadened to string as it is known to be exactly the inferred generic value.

@fatcerberus
Copy link

fatcerberus commented Jul 22, 2023

As always, #55060 (comment) is relevant here. The casted function claims to return Record<N, V> but you're only considering cases where it's called with a hardcoded string literal. If it's called with a variable, things go sideways:

Playground link

const entryName = 0.5 > Math.random() ? "foo" : "bar";
const casted = createMapWithCast(entryName, 3);
const inline = {
  [entryName]: 3,
} as const;

type Casted = typeof casted;
//  ^? { readonly foo: 3, readonly bar: 3 } - but you really only have one of them!
type Inline = typeof inline;
//  ^? { readonly [x: string]: 3 }

The return type of a function, even a generic one, is inferred from the function body. Because N can be a union of string literals, the only safe thing to infer for type of { [entryName]: 3 } is an index signature (as happens in the inline case when it's known to be a union). Inferring a mapped type over N is not sound and so you have to write the cast to tell the compiler you know what you're doing.

@fatcerberus
Copy link

See also: #27808.

@jcalz
Copy link
Contributor

jcalz commented Jul 22, 2023

#13948

@cefn
Copy link
Author

cefn commented Jul 24, 2023

Thanks for the clarification that the 'literal' nature of the value isn't carried by any type information, and so has to be considered to be potentially broad.

So I gather from the discussion that there is currently no way to flag that a narrowed literal is required, or to implicitly benefit from the case when N is not a union, (e.g. is known by code path to be a single value and therefore can be kept narrow throughout?). Imaginary case below...

function createMap<literal N extends string, const V extends unknown>(
  entryName: N,
  value: V
) {
  return {
    [entryName]: value,
  } as const;
}

The above pseudo-code would be a compile error if entryName had any alternate code paths leading to it than a literal.

Is tracing actual literals through code paths an impossible feature to consider or did I miss something important? Seems like it would have power, and for a lot of authored structures, things begin as literals, (from which inference is intended) and for others perhaps guard functions could facilitate.

@fatcerberus
Copy link

fatcerberus commented Jul 24, 2023

Yeah, basically there's no type you can currently write in place of Readonly<{ [k in N]: V }>, other than the overly-broad string index signature type, that will be typesafe for all possible Ns. I bring this up only because people sometimes fall into the trap of thinking, oh I didn't write an explicit return type so TS should be able to infer it from the call the same as if I wrote the expression inline, but that's just not at all how return-type inference works. If you can't correctly annotate the function with an explicit return type in a way that works for all possible type arguments, then TS can't infer one either.

Is tracing actual literals through code paths an impossible feature to consider or did I miss something important?

That would be #27808, more or less. Control flow analysis doesn't enter function calls (n.b. spiel above about function return types), so the way forward on this would be an annotation on the type parameter itself that explicitly prevents it from being instantiated as a union type in the first place. Beyond that, no additional "tracking" should be needed, as TS already has literal types.

For the record, your current makePair implementation is completely safe as long as you ensure N is never a union of string literal types (i.e. that it's always called directly with a string literal for entryName). It's just that TS can't currently enforce that, which is why you have to cast.

@RyanCavanaugh RyanCavanaugh added the Working as Intended The behavior described is the intended behavior; this is not a bug label Jul 24, 2023
@typescript-bot
Copy link
Collaborator

This issue has been marked as "Working as Intended" and has seen no recent activity. It has been automatically closed for house-keeping purposes.

@typescript-bot typescript-bot closed this as not planned Won't fix, can't repro, duplicate, stale Jul 27, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
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