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.