Does not seem like a regression. I've reproduced it in TS 5.4.2 and 4.8.4
// This works: a string-literal constant can be used in the computed property name of a type declaration
function simpleWorkingCase() {
const constVal = "xyz";
type U = {
[constVal]: true // works
// This does NOT work, although this constant also has a fixed string-literal value that is known at compile time
function bugCase() {
const brandedConstVal: "xyz" & {__foo:1} = "xyz" as any;
type U = {
[brandedConstVal]: true // ERROR!
🙁 Actual behavior
The second case gives compiler error ts(1170):
A computed property name in a type literal must refer to an expression whose type is a literal type or a 'unique symbol' type.
🙂 Expected behavior
Since the brandedConstVal
still has a literal value that is known at compile time, it should work as a computed property name too.
This is a little weird, since in reality it's impossible for any object key to be itself an object containing fields (the __foo
"brand" marker in this example). But the compiler already ignores that impossibility in allowing type declarations like string & { ...whatever ... }
in the first place – a useful trait in light of the nominal typing behaviors it enables. Since that is already supported, computed property names should ideally work consistently with it as well.
Additional information about the issue
The Playground link also illustrates a workaround (with two variations).
The workaround isn't ideal however. For example, if type U
is being declared in a different module from brandedConstVal
, that module would have to now export two copies of the const (brandedConstVal
plus the unbranded copy VAL
) – which is potentially more confusing for all clients of the module.
I'm also seeing some strange behavior with mapped object types: Playground link
In this case there's
(a) the tooltips for MappedType
vs. keyof MappedType
disagree with one another about what keys are present in the type! (the former says there are no keys, the latter lists the branded string as a key)
(b) loss of expected type-checking behavior (type checking behaves as if there are no keys, agreeing with the empty tooltip for MappedType
)
Seems like these issues probably has the same root cause, but please let me know if you'd rather I break that out as a separate GH issue.
Another oddity: the original bug above is that branded string constants can't be used as a computed property name in a type declaration. Branded string constants can however be used as a computed property name in an object value – but those strings are not correctly matched against plain/unbranded identifiers when type-checking:
function bugCase() {
const brandedConstVal: "xyz" & {__foo:1} = "xyz" as any;
type U = {
xyz: true
const u: U = { // ERROR: Property 'xyz' is missing in type '{ [x: string]: boolean; }'
[brandedConstVal]: true
...this despite the fact that branded string constants are assignable to matching plain/unbranded string types, outside the context of object keys:
function counterpoint() {
const VAL = "xyz";
const brandedConstVal: "xyz" & {__foo:1} = "xyz" as any;
let foo: "xyz" = VAL;
foo = brandedConstVal; // works!
More detailed Playground of this here.
So overall, it seems there's a general issue with branded string constants used as keys in object types. It's not totally disallowed to use them as keys, but you get a lot of undesirable type-checking behavior that is sometimes looser than you want (see my previous comment) and sometimes stricter than you want (original report plus this comment).
Since the brandedConstVal still has a literal value that is known at compile time, it should work as a computed property name too.
I'm not clear on what the alleged bug is here.
The error about the branded type not being a well-formed string name is a legitimate choice IMO. A branded type isn't a string type and there are architectural reasons why we need to be able resolve these without performing any type resolution. Once we open the door for anything but a bare string type, there are tons of new inconsistencies that would appear -- we can't remove this line between readily-resolved types and non-readily-resolved types, so the line has to be somewhere, and putting the line at "really, a string literal" seems like by far the least inconsistent and clearest behavior.
In value space there are different rules because, well, values aren't types, and we have to do something in most cases.
The behavior in this example
type MappedType = { [k in typeof brandedConstVal]: true }; // {} --> no keys are listed!
type MappedKeys = keyof MappedType; // '"xyz" & {__foo:1}' --> but key IS listed here!
is admittedly odd but sort follows from the definition of a homomorphic mapped type. It doesn't create an unsoundness, though:
type MappedType = { [k in typeof brandedConstVal]: true }; // {} --> no keys are listed!
type MappedKeys = keyof MappedType; // '"xyz" & {__foo:1}' --> but key IS listed here!
let s: MappedKeys = null! as any;
const test: MappedType = { foobar: 99 };
const p = test[s];
// ^ error, can't do that