Typesafe string enums in TypeScript.
npm install --save typescript-string-enums
Define an enum as follows:
// Status.ts
import { Enum } from "typescript-string-enums";
export const Status = Enum("RUNNING", "STOPPED");
export type Status = Enum<typeof Status>;
Use it elsewhere:
import { Status } from "./Status";
console.log(Status.RUNNING); // -> "RUNNING";
// Works fine.
const goodStatus: Status = Status.RUNNING;
// TypeScript error: Type '"hello"' is not assignable to type '"RUNNING" | "STOPPED"'
const badStatus: Status = "hello";
// Enum can be used for discriminated unions:
type State = RunningState | StoppedState;
interface RunningState {
status: typeof Status.RUNNING;
pid: number;
}
interface StoppedState {
status: typeof Status.STOPPED;
shutdownTime: Date;
}
function saySomethingAboutState(state: State) {
// The following typechecks.
if (state.status === Status.RUNNING) {
console.log("The pid is " + state.pid);
} else if (state.status === Status.STOPPED) {
console.log("The shutdown time is " + state.shutdownTime);
}
}
Instead of a list of values, an object may be passed instead if it is desired that the string values be different from the constant names. This also has the advantage of allowing JSDoc comments to be specified on individual values. For example:
export const Status = Enum({
/**
* Everything is fine.
*
* Hovering over Status.RUNNING in an IDE will show this comment.
*/
RUNNING: "running",
/**
* All is lost.
*/
STOPPED: "stopped",
});
export type Status = Enum<typeof Status>;
console.log(Status.RUNNING); // -> "running"
Two helper functions are provided: Enum.keys()
and Enum.values()
, which resemble Object.keys()
and Object.values()
but provide strict typing in their return type:
const FileType = Enum({
PDF: "application/pdf",
Text: "text/plain",
JPEG: "image/jpeg",
});
type FileType = Enum<typeof FileType>;
const keys = Enum.keys(FileType);
// Inferred type: ("PDF" | "Text" | "JPEG")[]
// Return value: ["PDF", "Text", "JPEG"] (not necessarily in that order)
const values = Enum.values(FileType);
// Inferred type: ("application/pdf" | "text/plain" | "image/jpeg")[]
// Return value: ["application/pdf", "text/plain", "image/jpeg"] (not necessarily in that order)
Enums are useful for cleanly specifying a type that can take one of a few specific values. TypeScript users typically implement enums in one of two ways: built-in TypeScript enums or string literals, but each of these has drawbacks.
Built-in enums have one big drawback. Their runtime value is a number, which is annoying during development and makes them unsuitable for use with external APIs.
enum Status {
RUNNING, STOPPED
}
const state = { status: Status.RUNNING, pid: 12345 };
console.log(state);
// -> { status: 0, pid: 12345 }. What status was that again?
// I hope you're not expecting other services to send you objects that look like this.
String literals make refactoring difficult. Suppose I have two enums:
type Status = "RUNNING" | "STOPPED";
type TriathlonStage = "SWIMMING" | "CYCLING" | "RUNNING";
Then if at a later stage I want to change Status
to be "STARTED" | "STOPPED"
, there's no easy
way to do it. I can't globally find/replace "RUNNING"
to "STARTED"
because it will also change
the unrelated string constants representing TriathlonStage
. Instead, I have to examine every
occurrance of the string "RUNNING"
to see if it needs to change.
Another disadvantage of string literals comes when using IDE autocomplete features. It's convenient
to be able to type Status.
and have autocomplete suggest Status.RUNNING
and Status.STOPPED
,
but with string literals no such suggestion appears with current IDEs.
I might try to solve both problems by introducing constants for the string literals, but this has issues as well:
// Typo on "STOPPED" not caught by anything below without additional boilerplate.
type Status = "RUNNING" | "STPOPED";
// Naive attempts to define constants for these don't work.
const StatusNaive = {
RUNNING: "RUNNING",
STOPPED: "STOPPED",
};
// Type error even though it shouldn't be, because StatusNaive.RUNNING has type
// string which is not assignable to Status.
const status: Status = StatusNaive.RUNNING;
// Correctly defining constants is annoyingly repetitive.
const Status = {
RUNNING: "RUNNING" as "RUNNING",
STOPPED: "STOPPED" as "STOPPED",
};
This library is effectively a programmatic version of these repetitive definitions. It attempts to provide the best of both worlds: string enums with the convenience of built-in enums.
This section is not necessary to use this library, but for those curious about how it is implemented, read on. The explanation uses the concepts of index types and mapped types, as described in TypeScript's Advanced Types page.
The relevant type declarations are as follows:
function Enum<V extends string>(...values: V[]): { [K in V]: K };
function Enum<
T extends { [_: string]: V },
V extends string
>(definition: T): T;
...
type Enum<T> = T[keyof T];
We are creating a overloaded function named Enum
and a type named Enum
, so both can be imported
with a single symbol.
Consider the first overload, which handles the case of variadic arguments representing the enum
values. In TypeScript, a string constant is a type (for example, in const foo = "Hello"
, the
variable foo
is assigned type "Hello"
). This means that the array
["RUNNING", "STOPPED"]
can be inferred to have type ("RUNNING" | "STOPPED")[]
, and so when it is passed into a function
with the above type signature, the type parameter V
is thus inferred to be
"RUNNING" | "STOPPED"
. Then the return type { [K in V]: K }
is a mapped type which describes an
object whose keys are the types that make up V
and for each such key has a value of the same type
as that key. Hence, the type of Enum("RUNNING", "STOPPED")
is
// This is a type, not an object literal.
{
RUNNING: "RUNNING";
STOPPED: "STOPPED";
}
Next, consider the second overload, which handles the case which takes an object of keys and values, and for the same of example consider
const Status = Enum({
RUNNING: "running",
STOPPED: "stopped",
});
The second type parameter V
is inferred as "running" | "stopped"
, which forces TypeScript to
infer the first type parameter T
as an object whose values are the specific string values that
make up V
. Hence, even though { RUNNING: "running", "STOPPED": "stopped" }
would have type
{ RUNNING: string; STOPPED: string; }
, passing it through Enum
causes its type to be inferred
instead as the desired
// Type, not object literal.
{
RUNNING: "running";
STOPPED: "stopped";
}
Next, consider the definition
type Enum<T> = T[keyof T];
This is an index type which describes, for a given keyed type T
, the type obtained by indexing
into T
with an arbitrary one of its keys (the syntax T[keyof T]
is meant to evoke the
expression t[key]
for some key
in t
). When passing in an arbitrary key to the object from the
previous step, we get a value which might be any one of the object's values, and so its type is thus
the union of the types of the object's values. Hence, Enum<typeof Enum("RUNNING", "STOPPED")>
evaluates to "RUNNING" | "STOPPED"
, which is what we want.
By contrast, the type definition for the case which takes an object of keys and values is
function Enum<
T extends { [_: string]: V },
V extends string
>(definition: T): T
This libary is heavily inspired by posts in this thread. In particular, credit goes to users @igrayson, @nahuel, and **@kourge.
Copyright © 2017 David Philipson