microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
100.57k stars 12.43k forks source link

Symbols in `as const` objects should be unique symbols #54100

Open nstepien opened 1 year ago

nstepien commented 1 year ago

Bug Report

🔎 Search Terms

symbol object "as const"

🕗 Version & Regression Information

⏯ Playground Link

Playground link with relevant code

💻 Code

const A = Symbol();
const B = Symbol();
const MyEnumA = { A, B } as const;
//    ^?
type MyEnumA = typeof MyEnumA[keyof typeof MyEnumA];
//    ^?
// good

function testA(val: MyEnumA) {}
testA(MyEnumA.A);
testA(MyEnumA.B);
testA(Symbol()); // good type error

const MyEnumB = { C: Symbol(), D: Symbol() } as const;
//    ^?
// should not use generic "symbol" type
type MyEnumB = typeof MyEnumB[keyof typeof MyEnumB];
//    ^?
// should not be symbol

function testB(val: MyEnumB) {}
testB(MyEnumB.C);
testB(MyEnumB.D);
testB(Symbol()); // should be a type error

🙁 Actual behavior

Symbols in as const-ed objects should be unique symbols, instead they're of type symbol.

🙂 Expected behavior

When I write

const obj = { a: Symbol() } as const;

I want obj.a to be a unique symbol.

What I'm trying to do is replace TS enums with symbol-based object "enums" in some scenarios, as it gives me greater typecheck-time and runtime guarantees.

I can do

const A = Symbol();
const MyEnum = { A } as const;
type MyEnum = typeof MyEnum[keyof typeof MyEnum];

and that works great, but I end up with many const ... = Symbol() which pollute the scope and can be misused, when I'd rather do

const MyEnum = { A: Symbol() } as const;
type MyEnum = typeof MyEnum[keyof typeof MyEnum];
fatcerberus commented 1 year ago

Possibly related: https://github.com/microsoft/TypeScript/issues/53276

nstepien commented 1 year ago

@fatcerberus I'm not convinced this issue is related.

fatcerberus commented 1 year ago

@nstepien Note I said "related", not "duplicate".

That said, I think the limitation here is that unique symbols explicitly have type typeof x, which implies there must be an x in scope that can be used with typeof. Given an anonymous object literal containing Symbol() calls, there's nothing for typeof to refer back to.

nstepien commented 1 year ago

Given

const obj = { x: Symbol() } as const

, would it be possible for its type to be typeof obj.x?

Although that wouldn't work in a case like

fn({ [dynamicKey]: Symbol() } as const)

as there is no specific name to refer back to.

Maybe unique symbol is good enough.

nstepien commented 1 year ago

I wonder if TS could use the description when available, i.e. Symbol('desc')'s type could be typeof Symbol('desc') or unique symbol 'desc'.

rotu commented 10 months ago

could be typeof Symbol('desc') or unique symbol 'desc'.

That's expressible as declare const mySymbol: symbol & {readonly description:'desc'}. Unfortunately, you have to give up uniqueness. (unique symbol) & {readonly description:'desc'} silently ignores the uniqueness. And {readonly description:'desc'} & (unique symbol) errors "'unique symbol' types are not allowed here."

Trying to fix this with an explicit annotation fails:

// ERROR: Type 'symbol' is not assignable to type 'unique symbol'.
const myEnum: {readonly a:unique symbol} = { a: Symbol() };

The best I achieve is with a cast:

const myEnum = { a: Symbol() } as { readonly a: unique symbol }

But this doesn't provide the type safety you seek because unique symbols decay too easily:

const myEnum = { a: Symbol() } as { readonly a: unique symbol }
const anotherEnum = { a: Symbol() } as { readonly a: unique symbol }

let b = myEnum.a
b = anotherEnum.a // OOPS! NO ERROR!
rotu commented 10 months ago

Wrote up some of the related issue in #56535 (namely that the type system disregards the description passed in to the Symbol constructor, even if it's a string literal)

JacobLey commented 8 months ago

+1 to this feature request.

Running into a similar issue where I would like the type generic to prefer a unique symbol.

e.g.

const doGenericThing = <T extends symbol>(val: T): { val: T } => {
    return { val };
};

Doesn't use unique symbol by default:

// { val: symbol }
const notUnique = doGenericThing(Symbol('abc'));

Can assign to a const first, which works but requires a technically unnecessary variable declaration

const uniqueSym = Symbol('abc');
// { val: typeof uniqueSym }
const unique = doGenericThing(uniqueSym);

Appending as const seems like a very reasonable approach to have it use a unique symbol.

// Not yet legal
const standaloneUnique = doGenericThing(Symbol('abc') as const);

Possibly off topic, but I wouldn't expect any of this unique functionality to apply to Symbol.for, unless symbols were to start tracking their description (see issue linked above).

However it appears that is not the case:

const sym = Symbol.for('abc');
const restrictToSym = (val: typeof sym) => {};

// _should_ work, but treated as a separate unique symbol
restrictToSym(Symbol.for('abc'));