Force generic type to be the type of a unique symbol in typescript

Viewed 628

After reading this answer about nominal typing I thought to move it one step further with

export type MARKED<C, M> = C & {
  readonly m: M;
};

and to be used like

const readonly SPECIAL: unique symbol = Symbol();
isSpecial(value: C): value is MARKED<C, typeof SPECIAL> {
  return someVerificationOf(value);
}

Here I try to be extra safe by replacing the "brand" with a unique symbol, though MARKED could as well be used with just a string as

class Foo { ... };
type SpecialFoo = MARKED<Foo, 'special'>;

Is there a chance to force the 2nd generic parameter of MARKED to be a unique symbol? I tried things like

type MARKED<C, M extends unique symbol> = ...

but this is obviously wrong.

2 Answers

Please see the docs

To enable treating symbols as unique literals a new type unique symbol is available. unique symbol is a subtype of symbol, and are produced only from calling Symbol() or Symbol.for(), or from explicit type annotations. The new type is only allowed on const declarations and readonly static properties, and in order to reference a specific unique symbol, you’ll have to use the typeof operator. Each reference to a unique symbol implies a completely unique identity that’s tied to a given declaration.

Especially this: in order to reference a specific unique symbol, you’ll have to use the typeof operator

Hence, in order to use unique symbol constraint, you need to use typeof:


const UNIQUE: unique symbol = Symbol();

type MARKED<U extends typeof UNIQUE> = U

type Result = MARKED<typeof UNIQUE>

But, I'd willing to bet, that you have several unique symbols and you don't want to create utility type for each symbol.

Let's take a look closer to this line: unique symbol is a subtype of symbol.

What it gives us?

unique symbol is a subtype of symbol. It gives us a door for checking whether some type is a subtype of symbol or not. It is already something.

So, we need to check whether passed type is a subtype of symbol:

type IsUnique<UniqueSymbol extends symbol> =
    (UniqueSymbol extends symbol
        ? (symbol extends UniqueSymbol
            ? false
            : true
        )
        : false
    )

// true
type Check = IsUnique<symbol & { tag: 42 }>

Above util does not meet our requirements.

We need also to check if symbol contains only symbol keys.

type IsExactKeys<S extends symbol> =
    (keyof S extends SYMBOL_KEYS
        ? (SYMBOL_KEYS extends keyof S
            ? true
            : false
        )
        : false
    )

type IsUnique<UniqueSymbol extends symbol> =
    (UniqueSymbol extends symbol
        ? (symbol extends UniqueSymbol
            ? false
            : IsExactKeys<UniqueSymbol>
        )
        : false
    )
// never
type Check = IsUnique<never>

Ooops, it still does not work with never.

Let's handle never type:


const UNIQUE: unique symbol = Symbol();
const REGULAR: symbol = Symbol()

type SYMBOL_KEYS = keyof symbol;

type IsExactKeys<S extends symbol> =
    (keyof S extends SYMBOL_KEYS
        ? (SYMBOL_KEYS extends keyof S
            ? true
            : false
        )
        : false
    )

// credits goes to https://github.com/microsoft/TypeScript/issues/40248
type IsNever<T> = [T] extends [never] ? true : false;

type IsUnique<UniqueSymbol extends symbol> =
    (IsNever<UniqueSymbol> extends true ? false
        : (UniqueSymbol extends symbol
            ? (symbol extends UniqueSymbol
                ? false
                : IsExactKeys<UniqueSymbol>
            )
            : false
        )
    );

type Assert<T extends true> = T

type Unique = {
    readonly StaticSymbol: unique symbol
}['StaticSymbol']

/**
 * Ok
 */
type Test0_0 = Assert<IsUnique<Unique>> // ok
type Test0 = Assert<IsUnique<typeof UNIQUE>> // ok

/**
 * Expected errors
 */
type Test1 = Assert<IsUnique<typeof REGULAR>> // error
type Test2 = Assert<IsUnique<
    & typeof REGULAR
    & { readonly tag: unique symbol }
>> // error

type Test3 = Assert<IsUnique<symbol>> // error
type Test4 = Assert<IsUnique<any>> // error
type Test5 = Assert<IsUnique<unknown>> // error
type Test6 = Assert<IsUnique<never>> // error


export type MARKED<C, M extends symbol> =
    IsUnique<M> extends true ? C & {
        readonly m: M;
    } : never

Playground

Seems to be it works now. Please keep in mind, above constraint is indirect. I'd say that it is a duck constraint.

If something is:

  • a subtype of symbol
  • has same keys as a symbol
  • is not never
  • symbol is not assignable to passed type but passed type is assignable to symbol

then, probably we have a deal with unique symbol.

Please add more tests.

Please trust above code only for 90%.

This is not a direct answer to the question formulated in the subject but to the targeted problem of getting nominal typing in typescript without the danger of intricate name collisions that may happen with "brand" type nominal typing as described in this answer.

Suppose we have

class Foo { ... }

yet under certain circumstances, in particular after verifying some constraint of a Foo object, we want to carry along the information that the constraint holds. We use the following:

const VERIFIED_FOO = Symbol();
type VerifiedFoo = Foo & {
  readonly [VERIFIED_FOO]: undefined;
}
isFoo(foo: Foo) foo is VerifiedFoo {
  return elaborateFooVerificationPassed(foo);
}

By using a Symbol as the property name in the attached marker type, we should be safe from spurious name clashes. Further, a VerifiedFoo can be used everywhere a Foo can be used, but not the other way round. Finally, after using the type guard isFoo, the compiler knows it and helps to not confuse the two types. This can be used, for example, like:

handle(foo: Foo): void {
  if (isFoo(foo)) {
    // Here we know it is a VerifiedFoo, not just Foo
    // and in particular the compiler knows it too.
    doHandle(foo);
  }
}
private doHandle(foo: VerifiedFoo): void {
  // no need to handle special cases for non verified foo objects
}
Related