In this blog post, we examine how TypeScript handles JavaScript symbols at the type level.
If you want to refresh your knowledge of JavaScript symbols, you can check out chapter “Symbols” of “Exploring JavaScript”.
For showing computed and inferred types in source code, I use the npm package asserttt
– e.g.:
// Types of values
assertType<string>('abc');
assertType<number>(123);
// Equality of types
type Pair<T> = [T, T];
type _ = Assert<Equal<
Pair<string>, [string,string]
>>;
symbol
and typeof MY_SYMBOL
Type inference usually gives us:
let
const
For example:
let value1 = 123;
assertType<number>(value1);
const value2 = 123;
assertType<123>(value2);
That is also true for symbols:
let SYM1 = Symbol('SYM1');
assertType<symbol>(SYM1);
const SYM2 = Symbol('SYM2');
assertType<typeof SYM2>(SYM2);
symbol
is the type of all symbols. typeof SYM2
is the type of one specific symbol. There is no way for us to create another value that matches typeof SYM2
:
function f(_sym: typeof SYM2) {}
f(SYM2); // OK
// @ts-expect-error: Argument of type 'symbol' is not assignable to
// parameter of type 'unique symbol'.
f(Symbol('SYM2')); // new, different value!
Note the type unique symbol
in the error message. We’ll get to what it is soon.
Out inability to create a new symbol that is equal to the original SYM2
is a JavaScript phenomenon:
> Symbol() === Symbol()
false
Symbols are similar to object literals in this regard:
> {} === {}
false
If we assign a variable SYM
with the type typeof SYM
to another variable X
, then the type of the latter is broadened to symbol
– even when we declare it with const
.
const SYM = Symbol('SYM'); // typeof SYM
function getSym(): typeof SYM {
const X = SYM; // symbol
// @ts-expect-error: Type 'symbol' is not assignable to
// type 'unique symbol'.
return X;
}
Related GitHub issue: “unique symbol
lost on assignment to const
despite type assertion”
unique symbol
The type unique symbol
is a subtype of symbol
. It means that this particular location holds a symbol with a particular type. It is very similar to typeof SOME_SYMBOL
but does not name the particular symbol. Each location of unique symbol
is different and incompatible with all other locations (similar to typeof SOME_SYMBOL
).
unique symbol
can be used in const
variable declaration and static readonly
properties. If we want to express uniqueness elsewhere, we have to use typeof S
– as we have done previously. Given that unique symbol
is basically another way of expressing typeof S
, it’s not very useful
const SYM: unique symbol = Symbol('SYM');
class MyClass {
static readonly SYM: unique symbol = Symbol('SYM');
}
The previous code is completely equivalent to:
const SYM = Symbol('SYM');
class MyClass {
static readonly SYM = Symbol('SYM');
}
There is one more location where we can use unique symbol
– as the type of a read-only property. That is done to declare the types of well-known symbols such as Symbol.iterator
(file lib.es2015.iterable.d.ts
):
interface SymbolConstructor {
readonly iterator: unique symbol;
}
Why is the name of the interface SymbolConstructor
? That’s due to how symbols are set up in file lib.es2015.symbol.d.ts
:
interface SymbolConstructor {
readonly prototype: Symbol;
(description?: string | number): symbol;
for(key: string): symbol;
keyFor(sym: symbol): string | undefined;
}
declare var Symbol: SymbolConstructor;
unique symbol
Consider the following type:
type Obj = {
readonly sym: unique symbol,
};
We can’t create an object that is assignable to Obj
:
const obj1: Obj = {
// @ts-expect-error: Type 'symbol' is not assignable to
// type 'unique symbol'.
sym: Symbol('SYM'),
};
const SYM: unique symbol = Symbol('SYM');
const obj2: Obj = {
// @ts-expect-error: Type 'typeof SYM' is not assignable to
// type 'typeof sym'.
sym: SYM,
};
In the previous subsection SymbolConstructor.iterator
was not meant for yet-to-be-created objects. It was meant for a single global value that already existed.
We can use symbols to define union types:
const ACTIVE = Symbol('ACTIVE');
const INACTIVE = Symbol('INACTIVE');
type ActSym = typeof ACTIVE | typeof INACTIVE;
const activation1: ActSym = ACTIVE;
const activation2: ActSym = INACTIVE;
// @ts-expect-error: Type 'unique symbol' is not assignable to
// type 'ActSym'.
const activation3: ActSym = Symbol('ACTIVE');
We have to use typeof
to go from program level to type level:
ACTIVE
typeof ACTIVE
How does a symbol-based union type compare to a string-based union type such as the one below?
type ActStr = 'ACTIVE' | 'INACTIVE';
To make it easier to compare ActSym
with ActStr
, let’s define the latter in a more complicated way (which we normally wouldn’t do):
const ACTIVE = 'ACTIVE';
const INACTIVE = 'INACTIVE';
type ActStr = typeof ACTIVE | typeof INACTIVE;
const activation1: ActStr = ACTIVE;
const activation2: ActStr = INACTIVE;
const activation3: ActStr = 'ACTIVE'; // OK!
What are the pros and cons of a string-based union type?
Pro: No need to import constants, we can simply mention the strings (see last line above).
Con: The union values are not type-safe. Strings are compared by value (not by identity), which is why a value can be mistaken to be a member of ActStr
when it actually isn’t. That kind of mistake cannot happen with symbol-based type unions.
undefined
and null
are often used as special “non-values” that are different from the actual values of a type:
type StreamValue =
| null // end of file
| string
;
However, a symbol can be a better, more self-explanatory alternative:
const EOF = Symbol('EOF');
type StreamValue =
| typeof EOF
| string
;
For more information on this topic, see chapter “Adding special values to types” in “Tackling TypeScript”.
Symbols also work well if we want to create an enum with new, unique values:
const Active = Symbol('Active');
const Inactive = Symbol('Inactive');
const Activation = {
__proto__: null,
Active, // (A)
Inactive, // (B)
} as const;
type ActivationType = PropertyValues<typeof Activation>;
type _ = Assert<Equal<
ActivationType, typeof Active | typeof Inactive
>>;
type PropertyValues<Obj> = Obj[Exclude<keyof Obj, '__proto__'>];
Why the intermediate step of declaring the variables Active
and Inactive
in the first two lines? Why don’t we create the symbols in line A and line B?
If we do that then:
Activation.Active
will have the type symbol
, not the type typeof Active
.Activation.Inactive
will have the type symbol
, not the type typeof Inactive
.As a result, ActivationType
will be symbol
. For a longer explanation, see the blog post “TypeScript enums: use cases and alternatives”.