Make unique types from basic types in TypeScript?

If you want these data types to be serializable in JSON for use in APIs or database structures, they need to remain string base types. There are two ways to do this.

(1) Implement ID types as string literals

Since string literals are strings, TypeScript mostly does the right thing. For your humans and animal examples, you could create the following string literal types and functions to safely create/coerce these types.

const setOfExclusiveIdsAlreadyCreated = new Set<string>();
const setOfExclusivePrefixesAlreadyCreated = new Set<string>();
const createMutuallyExclusiveId = <ID_TYPE extends string>(idType: ID_TYPE, idPrefix: string = "") => {
  // Ensure we never create two supposedly mutually-exclusive IDs with the same type
  // (which would, then, not actually be exclusive).
  if (setOfExclusiveIdsAlreadyCreated.has(idType)) {
    throw Error("Each type of ID should have a unique ID type");
  }
  // If setting a prefix, make sure that same prefix hasn't been used by
  // another type.
  setOfExclusiveIdsAlreadyCreated.add(idType);
  if (idPrefix && idPrefix.length > 0) {
    if (setOfExclusivePrefixesAlreadyCreated.has(idPrefix)) {
      throw Error("If specifying a prefix for an ID, each Id should have a unique prefix.");
    }
    setOfExclusiveIdsAlreadyCreated.add(idPrefix);
  }
  return (idToCoerce?: string) =>
    (typeof(idToCoerce) === "string") ?
      // If a string was provided, coerce it to this type
      idToCoerce as ID_TYPE :
      // If no string was provided, create a new one.  A real implementation
      // should use a longer, better random string
      (idPrefix + ":" + Math.random().toString()) as ID_TYPE;
}

//
// Create our human type and animal types
//

// The human type will appear to typescript to always be the literal "[[[Human]]]"
const HumanId = createMutuallyExclusiveId("[[[HumanId]]]", "human");
type HumanId = ReturnType<typeof HumanId>;
// The animal type will appear to typescript to always be the literal "[[[Animal]]]"
const AnimalId = createMutuallyExclusiveId("[[[AnimalId]]]", "animal");
type AnimalId = ReturnType<typeof AnimalId>;

const firstHumanId: HumanId = HumanId("Adam");
const randomlyGeneratedHumanId = HumanId();
const firstAnimalId = AnimalId("Snake");

// You can copy human types from one to the other
const copyOfAHumanId: HumanId = firstHumanId;
const anotherCopyOfAHumanId: HumanId = randomlyGeneratedHumanId;

// You CANNOT assign a human type to an animal type.
const animalId: AnimalId = firstHumanId; // type error

// You can pass an animal to a function that takes animals.
( (animal: AnimalId) => { console.log("The animal is " + animal) } )(firstAnimalId);

// You CANNOT pass a human to a function that takes animals.
( (animal: AnimalId) => { console.log("The animal is " + animal) } )(firstHumanId); // type error


interface Animal { animalId: AnimalId, makeSound: () => void };

const poodleId = AnimalId("poodle");
const animals: {[key in AnimalId]: Animal} = {
  [poodleId]: {
    animalId: poodleId,
    makeSound: () => { console.log("yip!"); }
  }
};

(2) Implement ID types as enums

// The human type will appear to typescript to be an enum
enum HumanIdType { idMayOnlyRepresentHuman = "---HumanId---" };
// Since enums are both types and consts that allow you to
// enumerate the options, we only want the type part, we
// export only the type.
export type HumanId = HumanIdType;

// Do the same for animals
enum AnimalIdType { idMayOnlyRepresentAnimal = "---AnimalId---" };
export type AnimalId = AnimalIdType;

const firstHumanId = "Adam" as HumanId;
const firstAnimalId = "Snake" as AnimalId;

// You can copy human types from one to the other
const copyOfAHumanId: HumanId = firstHumanId;

// You CANNOT assign a human type to an animal type.
const animalId: AnimalId = firstHumanId; // type error

// You can pass an animal to a function that takes animals.
( (animal: AnimalId) => { console.log("The animal is " + animal) } )(firstAnimalId);

// You CANNOT pass a human to a function that takes animals.
( (animal: AnimalId) => { console.log("The animal is " + animal) } )(firstHumanId); // type error


interface Animal { animalId: AnimalId, makeSound: () => void };

const poodleId = "poodle" as AnimalId;
const animals: {[key in AnimalId]: Animal} = {
  [poodleId]: {
    animalId: poodleId,
    makeSound: () => { console.log("yip!"); }
  }
};

// In order for JSON encoding/decoding to just work,
// it's important that TypeScript considers enums
// of this form (those with only one field that i
// represented as a string) as basic strings.
const animalIdsAreAlsoStrings: string = poodleId;

Considerations for both implementation styles

You'll need to avoid typing objects used as maps like this:

const animalIdToAnimalMap: {[key: AnimalId]: Animal} = {};

and instead like do this:

const animalIdToAnimalMap: {[key in AnimalId]?: Animal} = {}

Still, some typings won't work perfectly. For instance, if you use Object.entries(animalIdToAnimalMap), the keys will be strings and not AnimalId (which would be the type of keyof typeof animalIdToAnimalMap).

Hopefully TypeScript offer mutually-exclusive Ids in the future and improve the default typings for functions like Object.entries. Until then, I hope this still helps. These approaches sure helped me avoid a number of bugs where I would have otherwise passed ids in the wrong order or confused ids of different types.


In the "TypeScript Deep Dive" GitBook, there's a more succinct usage of enum to distinguish types:

Enums in TypeScript offer a certain level of nominal typing. Two enum types aren't equal if they differ by name. We can use this fact to provide nominal typing for types that are otherwise structurally compatible.

Applying the approach their to my original example, I get the following TypeScript which indeed does not let me call animalTest on a "human". :)

enum HumanBrand { _ = '' };
enum AnimalBrand { _ = '' };

declare type HumanId = string & HumanBrand;
declare type AnimalId = string & AnimalBrand;

const makeId = <T extends string>(id:string):T => { return id as T; };

function humanTest(id: HumanId): void {
}

function animalTest(id: AnimalId): void {
}

let h: HumanId = '1' as HumanId;
let a: AnimalId = makeId<AnimalId>("2");

animalTest(h); // Argument of type 'HumanBrand' is not assignable to parameter of type 'AnimalBrand'.

The _ = '' entry (or some other placeholder) is required.


As you mentioned, the types are structurally compatible. The only way to make them unique is to add unique properties to them.

If you only want to compiler to differentiate between the two, you can just add dummy unique members which make no runtime difference:

class HumanId extends id {
  private _humanId: HumanId; // Could be anything as long as it is unique from the other class
}
class AnimalId extends id {
  private _animalId: AnimalId;
}

I came across this question, but my use case had a minor twist: I wanted to introducing a unique type for number. Think of an API where you have e.g. hours: number, minutes: number, seconds: number, etc. but you want the type system to enforce correct usage of all units.

The blog post mentioned by @Evert is a great resource in that regard. The idea is to create an intersection type with some dummy that is never actually used. Creating new unique types can be abstracted away by a generic helper type. Illustrating on the example:

// Generic definition somewhere in utils
type Distinct<T, DistinctName> = T & { __TYPE__: DistinctName };

// Possible usages
type Hours = Distinct<number, "Hours">;
type Minutes = Distinct<number, "Minutes">;
type Seconds = Distinct<number, "Seconds">;

function validateHours(x: number): Hours | undefined {
  if (x >= 0 && x <= 23) return x as Hours;
}
function validateMinutes(x: number): Minutes | undefined {
  if (x >= 0 && x <= 59) return x as Minutes;
}
function validateSeconds(x: number): Seconds | undefined {
  if (x >= 0 && x <= 59) return x as Seconds;
}

Now a function f(h: Hours, m: Minutes, s: Seconds) cannot be called with just any number, but ensures full type safety. Also note that the solution has no memory/runtime overhead.

In practice this approach works well for me, because these "distinct" types can be used implicitly in places where number is required. Explicit conversion via e.g. as Hour is only necessary the other way around. A minor drawback is that expressions like hours += 1 need to be replaced by hours = (hours + 1) as Hours. As demonstrated in the blog post, the benefits can often outweigh the slightly more explicit syntax though.

Side note: I have called my generic type Distinct because the name feels more natural to me, and this is how the feature is called in the Nim programming language.

Tags:

Typescript