Symbols in TypeScript

[2025-02-17] dev, typescript
(Ad, please don’t block)

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”.

Notation used in this blog post  

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]
>>;

Types for symbols  

symbol and typeof MY_SYMBOL  

Type inference usually gives us:

  • broader, more general types for let
  • narrower, more specific types for 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

Pitfall: assignment broadens the type of a symbol  

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”

The type 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;

We can’t create properties whose type is 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.

Unions of symbol types  

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:

  • Program level: ACTIVE
  • Type level: 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.

Symbols as special values  

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 as enum values  

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”.

Further reading