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

Allow unique symbols to be interchangeable with types. Also assignability bug with Symbol(). #20898

Closed
rozzzly opened this issue Dec 26, 2017 · 31 comments
Labels
Needs More Info The issue still hasn't been fully clarified

Comments

@rozzzly
Copy link

rozzzly commented Dec 26, 2017

Now that we have #15473, when a symbolwhich is guaranteed to be unique by the ES2015 spec—is assigned to a const variable, it can be used (via computed property syntax) in place of a normal property name.

Quick contrived example:

export const FOO = Symbol('FOO');
export const BAR = Symbol('BAR');

export interface Thing {
    [ FOO ]: boolean;
    [ BAR ]: number;
}

const thing: Thing = undefined;
const foo = thing[ FOO ]; // ☝️ hover shows type of `foo` as `boolean`
const bar = thing[ BAR ]; // ☝️ hover shows type of `bar` as `number`

I love this, it allows a symbol to act as a unique identifier. But it truth, a symbol represents a unique value, I want to use that unique value to describe a type.

Another quick contrived example:

export const FOO = Symbol('FOO');
export const BAR = Symbol('BAR');

export function fooOrBar(value: FOO | BAR): string { // ❌ Cannot find name 'FOO'.
                                                     // ❌ Cannot find name 'BAR'.
    if (value === FOO) return 'You gave me FOO!';
    else if (value === BAR) return 'You gave me BAR!';
    else throw TypeError('What was that!?');
}

Is that something anyone else would want? A lot of us will use a symbol to define a unique constant, I think it just makes sense to be able to use that constant to... refer to that constant.


On a side note, I think I may have found a bug. So while typing up this issue, I found that I am able to achieve the desired behavior by using an intersection with a "tagged" object literal:

export type FOO = symbol & { FOO: true };
export const FOO: FOO = Symbol('FOO'); // ❓️ no error
export type BAR = symbol & { BAR: true };
export const BAR: BAR = Symbol('BAR'); // ❓ no error

While this is nice for me because it achieves what I want, I don't think it should work. Heres why:

So if you hover over the Symbol() constructor you'll see this:

var Symbol: SymbolConstructor
(description?: string | number) => symbol

That's what I expected to see. You (optionally) give it a string | number description and it gives you a symbol in return. So why doesn't the compiler freakout when I assign it to something that is symbol & { FOO: true }? If IRRC the spec says that no properties can be set on a symbol. I can't find where it said that, but really quickly in my DevTools Console, I did this which seems to affirm my belief:

$> const foo = Symbol('foo')
undefined
$> foo
Symbol(foo)
$> foo.foo = true
true
$> foo
Symbol(foo)
$> foo.foo
undefined

Perhaps there some special assignability feature for typescript primitive symbol that I'm overlooking? I don't know. But if you do:

export type FOO = string & { FOO: true };
export const FOO: FOO = String('FOO'); // ❌ Type 'string' is not assignable to type 'FOO'.
                                       // ❌ Type 'string' is not assignable to type '{ FOO: true; }'.

Looking at the type info for the String() constructor, it's nearly identical:

const String: StringConstructor
(value?: any) => string

so why does it behave differently?


Just out of curiosity, I tried:

export const FOO: boolean = Symbol('FOO');

And got no errors. Something is definitely broken because symbol is acting like any.

Because you will ask, I am currently running: typescript@2.7.0-dev.20171226, I've also tested this on 2.7.0-insiders.20171214, but the playground correctly gives me errors.

@sylvanaar
Copy link

sylvanaar commented Dec 26, 2017

Just to add to the mystery a little.

export const FOO: FOO = Symbol('FOO') as symbol; //  Type 'symbol' is not assignable to type 'FOO'.
                                                 // Type 'symbol' is not assignable to type '{ FOO: true; }'.

And this one is a little crazy!

const NEVER: never = Symbol("Never?");

Type of NEVER is now symbol! Symbol() is quite powerful!

@DanielRosenwasser DanielRosenwasser added the Bug A bug in TypeScript label Dec 27, 2017
@DanielRosenwasser DanielRosenwasser added this to the TypeScript 2.7 milestone Dec 27, 2017
@DanielRosenwasser
Copy link
Member

You can reference the type of the constant with typeof (the type query operator) in the following way:

const Foo = Symbol("foo");
type Foo = typeof foo;

@rozzzly
Copy link
Author

rozzzly commented Dec 27, 2017

@DanielRosenwasser not sure if we're on the same page here...

typeof Symbol('foo') // => symbol

I'm looking for the type of a symbol assigned to a constant to be mutually exclusive from other symbols.

Desired Behavior:

export const FOO = Symbol('FOO');
export const BAR = Symbol('BAR');

export function onlyFOO(value: FOO): void {
   // ...
}

onlyFOO(FOO); // ✔️ 
onlyFOO(BAR): // ❌ Type 'BAR' is not assignable to type 'FOO'.

@rozzzly
Copy link
Author

rozzzly commented Dec 27, 2017

okay, I went back and looked at the new unique keyword, and using that I was able to get what I wanted:

export const FOO: unique symbol = Symbol('FOO');
export const BAR: unique symbol = Symbol('BAR');

export function onlyFOO(value: typeof FOO): void {
   // ...
}

onlyFOO(FOO); // ✔️ 
onlyFOO(BAR): // ❌ Type 'unique symbol' is not assignable to type 'unique symbol'.

The error message: "Type 'unique symbol' is not assignable to type 'unique symbol'." sucks though. Would be nice if it contained the identifiers.

I thought perhaps defining a type alias for the type of the variable like so:

export const FOO: unique symbol = Symbol('FOO');
export type FOO = typeof FOO;
export const BAR: unique symbol = Symbol('BAR');
export type BAR = typeof BAR;

might cause the alias to show up in the assignment error message, but alas, it does not.


When I read #15473, I was thinking that because the declaration output of export const FOO = Symbol('FOO'); is export const FOO: unique symbol; therefore a const variable assignment where the right-hand side was simply the Symbol() constructor (ie: const FOO = Symbol('FOO');) would have the infered type of unique symbol.

I see why this isn't necessarily the case: in a declaration, you don't see the right-hand side of an assignment therefor the variable has to be explicitly marked as being unique.

@chriseppstein
Copy link

I also expected to be able to use the constant as a type in a similar way to string literals and so I think this pattern will be very common:

export const FOO: unique symbol = Symbol('FOO');
export type FOO = typeof FOO;

So common that I think it deserves some sugar to reduce the boilerplate. What do folks think about this?

export const type FOO = Symbol('FOO');

I don't see why such a declaration wouldn't also work for string and integer literals.

@rozzzly
Copy link
Author

rozzzly commented Jan 5, 2018

That would be so damn useful for typing redux actions!!

This is what I'm currently doing for any interested:

Expand to View (ommitted because it's super long... because it's redux)

./src/common/state/ui/share/actions.ts

import { Shareable } from './state';
import { Action } from 'common/state/KnownActions';

export type SHARE = 'ui/share::SHARE';
export const SHARE: SHARE = 'ui/share::SHARE';

export interface SHARE_Action extends Action<SHARE> {
    type: SHARE;
    payload: Shareable;
}

export function share(shareable: Shareable): SHARE_Action {
    return {
        type: SHARE,
        payload: shareable
    };
}

export type DISMISS = 'ui/share::DISMISS';
export const DISMISS: DISMISS = 'ui/share::DISMISS';

export interface DISMISS_Action extends Action<DISMISS> {
    type: DISMISS;
}

export function dismiss(): DISMISS_Action {
    return {
        type: DISMISS,
    };
}

export type UIShareActions = (
    | SHARE_Action
    | DISMISS_Action
);

export type UIShareActionIDs = (
    | SHARE
    | DISMISS
);

export default {
    SHARE,
    share,
    DISMISS,
    dismiss
};

./src/common/state/KnownActions.ts

export interface Action<T extends string> extends Action { // extends `redux`'s `Action` interface
    type: T;
    payload?: any;
    error?: Error;
    meta?: any;
}

export type KnownActions = (
    | SystemActions
    | UIActions
);

export type KnownActionsIDs = (
    | SystemActionIDs
    | UIActionIDs
);

super verbose, but has perfect type checking/completions

@mhegazy mhegazy removed the Bug A bug in TypeScript label Jan 9, 2018
@mhegazy mhegazy removed this from the TypeScript 2.7 milestone Jan 9, 2018
@mhegazy
Copy link
Contributor

mhegazy commented Jan 9, 2018

I am not sure i understand the issue really.. the examples you are referring to the type FOO as a unique symbol are working if you use typeof FOO instead.

@mhegazy mhegazy added the Needs More Info The issue still hasn't been fully clarified label Jan 9, 2018
@chriseppstein
Copy link

@mhegazy the issue is that for symbols, the constant it is assigned to is, functionally, the literal form of that value. For all other literal forms typeof is not required (E.g. fn(s: typeof 'my literal)') and this makes for an incongruous experience when working with unique symbol types.

@mhegazy
Copy link
Contributor

mhegazy commented Jan 9, 2018

the issue is that for symbols, the constant it is assigned to is, functionally, the literal form of that value. For all other literal forms typeof is not required (E.g. fn(s: typeof 'my literal)') and this makes for an incongruous experience when working with unique symbol types.

That is not totally accurate.. for the other literal types, you use the literal text (be it string, number, boolean) to identify the types and these are unambiguous in this location.

Enum literal types are similar in the sense that they are identifier names, but for these we have made sure that you can not merge other declarations with them in the past, so we were free to reuse their names as type names.

For const declarations that is a different story. you can today merge const C = Symbol(); and type C = number; and C in a type position has a well defined meaning. using the same variable name as a type is first a breaking change, and second is not inline with how declaration spaces work in TS and how merging work. see https://www.typescriptlang.org/docs/handbook/declaration-merging.html for more info.

@chriseppstein
Copy link

@mhegazy I wasn't trying to explain how typescript is implemented or how the typesystem works. I was just saying that as a developer, string and integer literals have the property of being their own type, but that there's no such convenient literal syntax for a unique symbol. But because one of the main use cases for unique symbols is to define object properties with them, there's a need to use typeof a lot or to always define a type type C = typeof C merging the type name with the constant. My suggestion is just to reduce boilerplate by allowing that to be done with a single statement.

@mhegazy
Copy link
Contributor

mhegazy commented Jan 17, 2018

For const declarations that is a different story. you can today merge const C = Symbol(); and type C = number; and C in a type position has a well defined meaning. using the same variable name as a type is first a breaking change, and second is not inline with how declaration spaces work in TS and how merging work. see https://www.typescriptlang.org/docs/handbook/declaration-merging.html for more info.

@chriseppstein
Copy link

@mhegazy I’m not sure how repeating yourself with no additional information is helpful here.

@mhegazy
Copy link
Contributor

mhegazy commented Jan 17, 2018

The suggestion you provide here, if implemented, would be a breaking change. today types and values with the same name already have a meaning.. this suggestion would change that meaning.. I would recommend reading the link i have shared.

@rozzzly
Copy link
Author

rozzzly commented Jan 17, 2018

How would this be a breaking change? What @chriseppstein proposes currently is a syntax error. Assuming this were implemented, the use of const type FOO would signal the author's intent to opt-into this departure from the normal declaration merging logic, any existing code would continue to work as is. Ergo, this is not a breaking change. Just new, optional, functionally.

So

export const type FOO = Symbol('FOO');
export const type BAR = 'BAR';

Would be functionally equivilant to

export const FOO: unique symbol = Symbol('FOO');
export type FOO = typeof FOO;
export const BAR: 'BAR' = 'BAR';
export type BAR = typeof BAR;

@mhegazy
Copy link
Contributor

mhegazy commented Jan 17, 2018

How would this be a breaking change?

In the OP, you have used export const FOO = Symbol('FOO'); to declare a type FOO. and that would be a breaking change.

We have talked about allowing a tagged symbol type, though that is slightly different from what the current feature does. that would be something we can consider. Please share some supporting scenarios why const s = symbol() is not sufficient.

@chriseppstein
Copy link

@mhegazy I think you misread my proposal. It was to add syntactic sugar to allow declaring a constant and a type of the same name with a single statement where the type is automatically defined as typeof <the constant>. I agree that declaring a type automatically would be a breaking change, which is why I suggested const type but I'm not wedded to any particular syntax. As @rozzzly points out, my proposal can certainly be implemented in a way as to avoid any chance of a breaking change if so desired through selection of syntax that is not currently valid.

@chriseppstein
Copy link

Please share some supporting scenarios why const s = symbol() is not sufficient.

A primary use case for symbols is to define an unambiguous object property that is guaranteed to not conflict when an object is extended by arbitrary code. In this case, the type of that well known property is always typeof <some constant> and so that property needs to be declared in interfaces and classes accordingly. The requirement to frequently type typeof for symbols or to consistently declare a type of the same name as the constant, is incongruous with string and number literal types which are their own type. This proposal removes boilerplate code and makes symbol literals work as class/interface field types more seamlessly.

@mhegazy
Copy link
Contributor

mhegazy commented Jan 17, 2018

A primary use case for symbols is to define an unambiguous object property that is guaranteed to not conflict when an object is extended by arbitrary code.

how do you expect your API users would use the symbol? through the constant or through Symbol.for("somekey")?

@mhegazy
Copy link
Contributor

mhegazy commented Jan 17, 2018

The requirement to frequently type typeof for symbols or to consistently declare a type of the same name as the constant, is incongruous with string and number literal types which are their own type.

Can you elaborate on why you need to write typeof multiple times? why not just use the const directelly?

@chriseppstein
Copy link

chriseppstein commented Jan 17, 2018

how do you expect your API users would use the symbol? through the constant or through Symbol.for("somekey")?

The constant. I never used Symbol.for() in any example and the limitations of Symbol.for() for interface definitions are already well documented with this new feature.

Can you elaborate on why you need to write typeof multiple times? why not just use the const directelly?

I'm actually not sure if you're trolling me here. This issue starts with "when trying to use the const directly it gives me an error" to which @DanielRosenwasser replied (#20898 (comment))

(paraphrasing) use this boilerplate:

const Foo = Symbol("foo");
type Foo = typeof foo;

To which I replied, (paraphrasing) "hey, what if we just could type that with one line instead of two."

Why are we going around in circles here?

@mhegazy
Copy link
Contributor

mhegazy commented Jan 17, 2018

I'm actually not sure if you're trolling me here.

I am not. i am genuinely trying to understand the context for this request. all the code samples in this thread are just snippets. No one put this request in the context of a use case.. like i am not sure i understand what your API is doing, and why you need to write typeof Foo that many times.. it seems to me most of the time you are writing Foo either in an interface declaration interface i { [Foo] :string } or in property access o[Foo]. in the rare occasion that you want users to pass you an object, and a symbol to use to index into it, why is not typeof Foo too verbose? is there a scenario you are trying to achieve that makes it a must to keep writing typeof Foo? or is it just one place in your API? are you looking for a symbol enum instead (tracked by #18408)?

@rozzzly's comment seems to suggest he wants a tagged symbol type, which lends itself to symbol.for(), and this one i understand, but i am not sure i understand the other one.

@chriseppstein
Copy link

chriseppstein commented Jan 17, 2018

why is typeof Foo too verbose?

As I've said, It's more verbose than string and integer literals and incongruous with the dev experience of using those types. It's not a big deal, I just think this makes the code marginally nicer to use and read.

is there a scenario you are trying to achieve that makes it a must to keep writing typeof Foo?

On a per-symbol basis I do not think that there will be very many uses of typeof, but if you have some code that is working with even 3-5 symbols per interface and you have a few such interfaces, it starts to look messy and have a lot of boilerplate in aggregate.

are you looking for a symbol enum instead?

Nope. I'm just trying to make some interfaces and classes that have some symbol properties.

which lends itself to Symbol.for()

It's my understanding that with this feature, at this time, multiple calls to with the same string to Symbol.for() are not understood to be the same unique symbol by the type checker and that a single constant must be used to get unique symbol type checking to work. As such, I did not personally, consider the distinction important at this time. Personally, I'd love to see a literal syntax to replace Symbol.for() not unlike what ruby has with :foo... but I don't consider that to be on-topic for this issue.

@mhegazy
Copy link
Contributor

mhegazy commented Jan 17, 2018

Nope. I'm just trying to make some interfaces and classes that have some symbol properties.

still confused, so why isn't it just:

const sym = Symbol();

interface I {
    [sym]: string;
}
class C implements I {
    [sym]: string;
}

@chriseppstein
Copy link

@mhegazy Ah, that's fair. sorry, I got my story a bit mixed up here. In the code I was working on when I bumped into this, I was trying to create a discriminator property from symbols instead of strings.

export const NODE_TYPE = Symbol("Node Type");
export const A = Symbol("Node A");
export const B = Symbol("Node B");
export const C = Symbol("Node C");
export interface NodeA {
  [NODE_TYPE]: typeof A;
}
export interface NodeB {
  [NODE_TYPE]: typeof B;
}
export interface NodeC {
  [NODE_TYPE]: typeof C;
}
export type Node = NodeA | NodeB | NodeC;

Whereas when this code was using a string type it was like so:

export interface NodeA {
  node_type: 'a';
}
export interface NodeB {
  node_type: 'b';
}
export interface NodeC {
  node_type: 'c';
}
export type Node = NodeA | NodeB | NodeC;

I found the need to use typeof off putting and would prefer:

export const NODE_TYPE = Symbol("Node Type");
export const type A = Symbol("Node A");
export const type B = Symbol("Node B");
export const type C = Symbol("Node C");
export interface NodeA {
  [NODE_TYPE]: A;
}
export interface NodeB {
  [NODE_TYPE]: B;
}
export interface NodeC {
  [NODE_TYPE]: C;
}
export type Node = NodeA | NodeB | NodeC;

@fbartho
Copy link

fbartho commented Jan 18, 2018

Not to jump in from a weaker understanding of the topics under discussion, but if we are proposing enhancing syntax in this way, would we also consider having an enum where the values are Symbols?

@mhegazy
Copy link
Contributor

mhegazy commented Jan 18, 2018

Not to jump in from a weaker understanding of the topics under discussion, but if we are proposing enhancing syntax in this way, would we also consider having an enum where the values are Symbols?

Please see #18408

@mhegazy
Copy link
Contributor

mhegazy commented Jan 18, 2018

@chriseppstein Thanks for the explanation. Seems you need access to both the type and the value. ( i am assuming somewhere you have if (node[Node_Type] === A) ... and thus the solution proposed by @rozzzly would not fit your use case. type A = Symbol("Node A"); does not generate any code. I do not think we want to add a new syntax (const type ..) just for symbols that makes a type generate code. we could discuss it, but we have discussed similar proposals in the past and concluded we do not want to do that.
I would say if we were going to take on @rozzzly's proposal, it would be in the type space only. i.e. you will have to cast your way around const A = <symbol("Node A")><symbol>Symbol("Node A"); which a'int pretty.

I think a symbol enum is the best solution here. here are the reasons,

  • enums give you access to both value and type
  • enum members are themselves type names, so you do not need to write typeof E.A, just E.A.
  • enums give you the union type automatically, so no need for Node there

@chriseppstein
Copy link

@mhegazy I guess it would work, but you'd have to augment the symbol enum across modules with additional values for cases where you have an extensible set of types, which is also kind of annoying. For my current use cases, it would be fine though.

@sylvanaar
Copy link

Does any language have such extensible enums? @chriseppstein?

@chriseppstein
Copy link

@sylvanaar You can extend enums in TS 🤷‍♂️

enum Foo { a, b, c }
enum Foo { d = 3, e, f}

So I assume that you can do so across modules using module augmentation.

@typescript-bot
Copy link
Collaborator

Automatically closing this issue for housekeeping purposes. The issue labels indicate that it is unactionable at the moment or has already been addressed.

@microsoft microsoft locked and limited conversation to collaborators Jul 3, 2018
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Needs More Info The issue still hasn't been fully clarified
Projects
None yet
Development

No branches or pull requests

8 participants