3

How can Typescript accept / transpile this obviously erroneous excerpt:

enum Test {
  T1 = 'T1',
  T2 = 'T2',
}
const arg: { member: Test } = { member: Test.T1 };
const map: { [t in Test]?: { index: number; title: string; isActive?: boolean } } = {
  [arg.member]: 100, // ok, shouldn't be a number!
};

TypeScript correctly infers arg.member to a member of enum Test but still allows it. The following excerpt correctly does not transpile:

enum Test {
  T1 = 'T1',
  T2 = 'T2',
}
const arg: Test = Test.T1;
const map: { [t in Test]?: { index: number; title: string; isActive?: boolean } } = {
  [arg]: 100 // error
};

If we, however, cast the value of arg on assignment, then it is allowed again:

enum Test {
  T1 = 'T1',
  T2 = 'T2',
}
const arg: Test = Test.T1 as Test; // cast here
const map: { [t in Test]?: { index: number; title: string; isActive?: boolean } } = {
  [arg]: 100 // ok
};

Replacing the index type Test with string solves the issue and TypeScript correctly throws an error

const arg: { member: string } = { member: 'hello' };
const map: { [t in string]?: { index: number; title: string; isActive?: boolean } } = {
  [arg.member]: 100, // error
};

Any clues?

fast-reflexes
  • 4,891
  • 4
  • 31
  • 44
  • You've run into the issue at [ms/TS#38663](https://github.com/microsoft/TypeScript/issues/38663) which is due to the bug at [ms/TS#27144](https://github.com/microsoft/TypeScript/issues/27144) where weak types (with all optional properties) are erroneously assignable to index signature types where the property values don't match. Does that fully address your question? If so I'll write up an answer explaining; if not, what am I missing? – jcalz Aug 02 '23 at 13:08
  • @jcalz yeah seems related to the trail of issues linked to. Any answer with some explanations of the different cases above would be highly appreciated and especially if there's some best practices way around this problem for now :) – fast-reflexes Aug 02 '23 at 13:53

1 Answers1

1

This is caused by two interacting issues, each of which is considered a bug in TypeScript, and neither of which show any indication of being addressed in the near future.


The first is microsoft/TypeScript#13948: when you use a computed property whose type is not a single literal, then the object ends up being given an index signature. It's as if the type of the computed property has been widened.

When you write the following line, the type of arg is Test, a union enum:

const arg: Test = Test.T1 as Test; // assert here

That means arg is a union of literal types and not a single literal. (Note that if you don't use the as Test, type assertion, the compiler will use the assignment const arg: Test = Test.T1 to narrow the apparent type of arg to just the single literal Test.T1, which changes things.) So if we use arg as a computed property key, we get the surprising widening:

const v = { [arg]: 100 };
/* const v: {
    [x: string]: number;
} */

This widening isn't really incorrect, but it's not what most people expect to see. Presumably you'd expect something like Partial<Record<Test, number>>, or maybe { T1: number } | { T2: number }, or maybe { T1: number, T2?: never } | { T1?: never, T2: number }. But none of that happens, mostly because it would be too expensive in general to synthesize new types like that... at least according to this comment on a related issue microsoft/TypeScript#48569.

Note that even though this is classified as a bug, when I've seen the TS team mention it (like the above-linked comment) they tend to treat it as the best of bad options, so it is acting like a design limitation, and not something we can expect to be "fixed".


The second is microsoft/TypeScript#27144. Index signatures are apparently assignable to weak object types (those whose properties are all optional), even if the property value types from each are not compatible.

So given the v object above of type {[k: string]: number}, the following assignment gives no error, even though you are potentially assigning a number to a boolean:

const w: { a?: boolean, b?: boolean } = v; // no error!

This is the same general situation as the problem in your example:

type MyType = { index: number; title: string; isActive?: boolean };
const m: { T1?: MyType; T2?: MyType } = v; // no error!

There was an effort to address this in microsoft/TypeScript#27591 but it apparently broke some real world code and was eventually abandoned.

So that's why it's happening. Two low-priority bugs or limitations in the language. The only way to "fix" it is to work around it.


So how do we work around it?

That really depends on use cases; my usual approach is to employ a helper function to serve as an alternative typing for computed properties, as mentioned in this comment:

function kv<K extends PropertyKey, V>(
  k: K, v: V
): { [P in K]: { [Q in P]: V } }[K] {
  return { [k]: v } as any
}

I've explicitly described how I want computed properties to behave there, by distributing any union in K over the output type, and wrapped that behavior in the helper function kv(). If I use that instead, then the types behave more reasonably:

const v2 = kv(arg, 100);
// const v2: { T1: number; } | { T2: number; }

const mapBad: { [K in Test]?: MyType } = kv(arg, 100);  // error!
const mapGood: { [K in Test]?: number } = kv(arg, 100); // okay!

Yes, it's clunky, but until and unless computed properties natively behave like kv() (or some more appropriate output type), this is the best I can do, in the sense that the unsafeness of the type assertion is limited to the implementation of kv() and I don't have to repeat it for each use.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Thanks a lot for a splendid and understandable answer giving a lot of insight. I'm just missing one small piece of info. Have a look at the follow-up and see if this can be added somewhere in your answer? :) – fast-reflexes Aug 03 '23 at 08:51
  • I'm not really prepared to read and respond to another question-length addition to the original question, and followup questions generally belong in their own post and not edited into the question. Still, if you have an example you're wondering about, could you summarize it in a few lines of code instead of multiple enumerated lists with verbal descriptions that I need to cross reference? Something that jumps out at me instead of "cases 1, 2, 4 and 8 don't properly describe the phenomenon laid out in (2b) when foo is intersected with bar". – jcalz Aug 03 '23 at 12:28
  • Sorry, I'm super grateful to you for sharing and enlightening so very sorry that I summarized it in an elaborate way, not my intention. I tried to simplify it, just looking to understand the second limitation you described. – fast-reflexes Aug 03 '23 at 12:59
  • Your mapped types (with `in`) are obscuring things; the rule still applies, In `ex2` you are not assigning an index signature: `{ [k in A]: number }` evaluates to `{0: number, 1: number}` which is not an index signature. In `ex2` `ex3`, `ex4`, and `ex5` you are not assigning to a weak type; `{[K in string]?: V}` evaluates to `{[k: string]: V | undefined}`, another index signature and not a weak type. I do hope you'll now remove the whole extra section from your question and we can leave this alone. If you still have questions about how mapped types are evaluated please make a new post. – jcalz Aug 03 '23 at 13:10
  • 1
    Ok, then I understand! Hope I didn't make you too upset with these questions, it was not my intention! Just a regular Joe trying to understand you know :) Thanks! – fast-reflexes Aug 03 '23 at 13:35