microsoft / TypeScript

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

Symbols produced by an alias of the Symbol constructor cannot be used as object keys in types/interfaces/classes, etc. #53282

Open bordoley opened 1 year ago

bordoley commented 1 year ago

Bug Report

Symbols returned by an alias of the global Symbol constructor cannot be used as objects keys. See this playground example.

In and of itself this is not a bug per se, but it can result in increase bundle size when using a minifier such as terser. Terser does not mangle global names such as Math, Symbol, etc. (see: https://github.com/terser/terser/issues/1337), so a common trick to avoid them reappearing in minified code is to assign the global name to a local variable.

For instance, consider the following typescript code that uses the globals Symbol and Math: playground link

const Symbol_keyA = Symbol();
const Symbol_keyB = Symbol();
const Symbol_keyC = Symbol();

type TypeWithSymbolKey = {
  [Symbol_keyA]: number
  [Symbol_keyB]: number
  [Symbol_keyC]: number
 };

const f = (y: number): number => {
  let x = Math.abs(y);
  x = Math.min(x, -5);
  x = Math.log(x)
  return x;
}

f(-10);

when compiled to javascript by TS it generates the following output:

"use strict";
const Symbol_keyA = Symbol();
const Symbol_keyB = Symbol();
const Symbol_keyC = Symbol();
const f = (y) => {
    let x = Math.abs(y);
    x = Math.min(x, -5);
    x = Math.log(x);
    return x;
};
f(-10);

and when minified using terser you get the following output (you can try this using https://try.terser.org):

Symbol(),Symbol(),Symbol();(l=>{let a=Math.abs(l);a=Math.min(a,-5),a=Math.log(a)})(-10);

Now consider this minor tweak to the original code, which aliases the global Math name to a local variable: playground link


const Symbol_keyA = Symbol();
const Symbol_keyB = Symbol();
const Symbol_keyC = Symbol();

type TypeWithSymbolKey = {
  [Symbol_keyA]: number
  [Symbol_keyB]: number
  [Symbol_keyC]: number
 };

 const math = Math;

const f = (y: number): number => {
  let x = math.abs(y);
  x = math.min(x, -5);
  x = math.log(x)
  return x;
}

f(-10);

when compiled to javascript by TS it generates the following output:

"use strict";
const Symbol_keyA = Symbol();
const Symbol_keyB = Symbol();
const Symbol_keyC = Symbol();
const math = Math;
const f = (y) => {
    let x = math.abs(y);
    x = math.min(x, -5);
    x = math.log(x);
    return x;
};
f(-10);

which terser can minify to:

Symbol(),Symbol(),Symbol();const l=Math;(o=>{let b=l.abs(o);b=l.min(b,-5),b=l.log(b)})(-10);

Obviously in this small example there isn't a significant difference in minified size, but in a large library that includes hundreds of calls to a top level name, this reduction can be significant. Its not uncommon to re-export math functions from an internal module to enable this type of minified output when one is focused on reducing output size.

Unfortunately, for libraries written in TS that choose to use symbols for object keys, class methods, etc. the resulting output can become littered with invocations of the Symbol constructor and there is no typesafe way in typescript to work around this.

🔎 Search Terms

Symbol

🕗 Version & Regression Information

4.95

⏯ Playground Link

Symbols returned from alias to Symbol not working as keys: playground example

💻 Code

🙁 Actual behavior

Symbols returned by aliases of the global Symbol constructor are not unique and cannot be used as keys in interfaces and types.

🙂 Expected behavior

Symbols returned by aliases of the global Symbol constructor should be unique symbols usable as keys in interfaces and types.

ExE-Boss commented 1 year ago

This would be solved by #37469.

bordoley commented 1 year ago

I did develop a particularly ugly work around for myself that takes advantage of typescript's JS support( "allowJs": true in tsconfig.json), JS type annotations and .d.ts files.

// symbol.js

const symbol = Symbol
export default symbol;
// module.symbols.js

import symbol from "./symbol.js";

/** @type {unique symbol} */
export const Symbol_keyA = symbol();

/** @type {unique symbol} */
export const Symbol_keyB = symbol();

/** @type {unique symbol} */
export const Symbol_keyC = symbol();
// module.symbols.d.ts
export declare const Symbol_keyA: unique symbol;
export declare const Symbol_keyB: unique symbol;
export declare const Symbol_keyC: unique symbol;

The .d.ts is needed for type checking, and the JS type annotations ensure that the compiler generates the correct output declarations when emitting compiled js with declarations.

RyanCavanaugh commented 1 year ago

It seems like terser should implement the linked suggestion. The code is semantically minifiable either way.

Obviously output size matters at some level, but this is going into gzip on the wire, right? Apart from the very first utterance of Symbol, it's the same entropy from there on out even if you have the alias.

bordoley commented 1 year ago

@RyanCavanaugh I generally agree. The reason I raised this issue was to highlight that the confluence of these two issues can result in bundle size issues today, in lieu of gzip. I suspect that terser doesn't mangle these globals due to the global scope being mutable by polyfills etc. so there may be an order of operations concern to account for, but I'm not an expert here.

That said in my own code, I don't straight re-export the Symbol constructor, but also add dev checks that removes the symbol description when bundled by terser in production mode. This allows me to avoid the use of the unsafe-symbols configuration.

const symbol = label => Symbol(__DEV__ ? label : undefined);

As a matter of semantic correctness, I think typescript should support this use case since it's valid javascript, but is it high priority, probably not given there are workarounds.