One of the first things I liked about the brave new world of TypeScript was the TypeScript enum. I had previously used them in C# and felt a reassuring familiarity.
Enums are a set of named constants that can take either a numeric or string form. I have always used the string-based enum, which I will use for all the examples in this post:
enum State { on = 'ON', off = 'OFF, };
I used enums in all sorts of inappropriate places, such as the string
type in Redux actions before @reduxjs/toolkit helped alleviate the notorious Redux boilerplate:
enum AuthActionTypes { SetForcePasswordChange = "SET_PASSWORD_CHANGE" } interface ForcePasswordChange { type: AuthActionTypes.SetForcePasswordChange; } export const forcePasswordChange = (): ForcePasswordChange => ({ type: AuthActionTypes.SetForcePasswordChange });
My motivation was to avoid annoying string typo errors, and for this requirement, it works.
I became more adventurous as my confidence grew with this new alluring Frankenstein construct that exists both at build time and at run time.
Enums seemed an excellent choice for modeling states in a finite state machine.
What I did not know at the time was that I was missing one of TypeScript’s most outstanding features that goes a lot further than just making sure I have a mutually exclusive set of constants:
enum AuthenticationStates { unauthorised = "UNAUTHORISED", authenticating = "AUTHENTICATING", authenticated = "AUTHENTICATED", errored = "ERRORED", forcePasswordChange = "FORCE_PASSWORD_CHANGE" }
In the above example, I have an AuthenticationStates
enum that models an authentication workflow.
A user starts in an UNAUTHENTICATED
state before transitioning to AUTHENTICATING
, etc. My carefully crafted enum ensures that the user cannot be in more than one contradicting state at any one time. For example, they cannot be AUTHENTICATED
and AUTHENTICATING
.
I then realized that I would need additional data, for example, if an error occurred then I would need to know what the actual Error
object was.
I initially modeled the new requirements like this:
enum AuthenticationStates { unauthorised = "UNAUTHORISED", authenticating = "AUTHENTICATING", authenticated = "AUTHENTICATED", errored = "ERRORED", } type State = { current: AuthenticationStates; isLoading: boolean; authToken?: string; error?: Error; }; const current: State = { kind: AuthenticationStates.authenticated, isLoading: false, authToken: 'token', error: undefined };
I diligently ensured that each authentication state could have the same type of fields.
The problem with this approach is that each state has the same fields and could be a great source of bugs as I might get lazy and start copying and pasting.
I then discovered discriminated unions that are also known as algebraic data types.
If you want to impress people at a party, then telling them that you use algebraic data types daily is a guaranteed home run!
In TypeScript we can create a string union that is very similar to our Authentication
enum:
type AuthenticationStates = | "UNAUTHORISED" | "AUTHENTICATING" | "AUTHENTICATED" | "ERRORED";
I can use this as a more powerful string parameter type that gives better guarantees about what values are allowed.
Unions in TypeScript can be unions of many things and not just primitive types. We can make the world a better place by creating a union that only needs to have the same kind
field as each element in the union. The kind
field will act as the discriminator:
export type AuthenticationStates = | { kind: "UNAUTHORISED"; context: { isLoading: false }; } | { kind: "AUTHENTICATING"; context: { isLoading: true; }; } | { kind: "AUTHENTICATED"; context: { isLoading: false; authToken: string; }; } | { kind: "ERRORED"; context: { isLoading: false; error: Error }; };
The above type is both beautiful executable documentation, and we can, at a glance, see all the available states in the workflow.
The discriminator in the above example is the kind
field that the compiler uses to type narrow
or apply more specific rules as it determines which exact element of the union a variable might be.
The critical takeaway here is that only the appropriate data is available on each type.
We have no business trying to access an authToken
if we are not currently in the AUTHENTICATED
state.
What is uber exciting is that the compiler can enforce this order of correctness better than a programmer who has spent too much time on the JVM can during a code review.
Below is an illustration of how TypeScript can type narrow on a discriminator of a union:
const transition = (state: AuthenticationStates) => { switch (state.kind) { case "UNAUTHORISED": { console.log(state.context.userName); // only available in UNAUTHORISED // this is hot!! the compiler will not allow us to access the authToken in this state console.log(state.context.authToken); // Property 'authToken' does not exist on type '{ isLoading: false; userName: string; password: string; }' break; } case "AUTHENTICATING": console.log(state.context.userName); // Property 'userName' does not exist on type '{ isLoading: true; }'. // Type 'false' is not assignable to type 'true' state.context.isLoading = false; // The only assignable value is true in this state state.context.isLoading = true; break; case "AUTHENTICATED": // here and only here do we have an authToken console.log(state.context.authToken); break; case "ERRORED": console.log(state.context.error); break; } };
You can also check out the CodeSandbox here.
We can only access specific data when the compiler has type narrowed by using the kind
discriminator field.
The best example is this:
The compiler will error if we try and use the authToken
in the wrong state.
Any attempts to model state with Booleans will fail in an explosion of contradicting variables and a stressed developer.
For example, if we tried this approach:
const isAuthenticated: boolean = false; const isErrored: boolean = false; const isLoading: boolean = false;
It will not take long before we start combining these suckers into a mess of tangled logic that just keeps on spinning.
if (isErrored &&. isAuthenticated === false) { // do this } else if (isLoading && is isErrored) { // do something else
These fancy-sounding algebraic data types are nothing more than a way of saying that a type is composed of other types. That is it. Not that fancy at all.
Unions serve as great-looking executable documentation and also keep the errant programmer on the straight and narrow which is especially prevalent in the Wild West landscape of what used to be JavaScript programming.
Algebraic data types have existed in functional languages such as Haskell for some time, and it is exciting that TypeScript has brought them to the great unwashed of frontend developers.
LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]
8 Replies to "Put the TypeScript enums and Booleans away"
What about when you have a union of strings and you have strings fed into your app that implicitly acquire this string union but then you need to change one of the string values in your union?
Now you get rogue errors from strings that no longer match the spec all throughout your app.
You can also do algebraic data types and exhaustive pattern matching with enum values.
{type:MyEnum.a} | {type:MyEnum.b} will work the same.
I personally often prefer enums because you can search for usage of values, and rename them easily.
what am I missing? Why is the enum version better? What does it give me apart from more code?
If you don’t use the enum, you just rename the values in the union.
https://codesandbox.io/s/typescript-playground-export-forked-wy04e?file=/index.ts
Yes, I’m curious to know why the author did not conclude with that solution which is essentially the best of both world. As you said it’s much better for searching and refactoring and it’s the same behavior as using plain strings.
It boils down to enums being references and strings being values. You can navigate through your code easily using enums. You can easily refactor them using code editor features like “rename symbol”. This for example does not work with strings. I’ve tested this with the example code you’ve linked in your comment.
Also, you can define the value of “UnionSwitch[‘kind’]” with a simple variable: “const on = ‘on'” and use it inside an object of type “UnionSwitch”. This “on” variable however cannot be found using “go to references”. It’s like a blind spot. This however is not a problem with enums – unless you really want it to be.
I think all of the replies so far have missed the point. I also mentioned booleans as a bad way of modelling state.
The point is not about the string values and being able to refactor them, that seems hardly worthy of a post.
The point is that enums like this
enum Auth {
unauthenticated = ‘unauthenticated’,
authenticated = ‘authenticate,
}
or
const isAuthenticated = true;
const authenticated = false;
are bad ways of modelling state and discriminated unions where the typescript compiler can type narrow on a string field is far superior:
type Auth =
| {
kind: “UNAUTHORISED”;
context: {
isLoading: false
};
}
| {
kind: “AUTHENTICATING”;
context: {
isLoading: true;
};
}
Unfortunately everyone seems fixated on the string values….that is a small point.
all the replies so far have missed the point of the post and seem fixated on the string values, and the ease of refactoring string values.
The point of the post is that booleans and enums are bad ways of modelling state.
Modelling state is nothing to do with refactoring string values. That is not exactly worthy of a post.
Discriminated unions are far superior and the example in the post has an authenticated state and the compiler only allows access to the authtoken field when it has type narrowed when the discriminator is of `kind: ‘Authenticated`
refactoring string values is not exactly something to get excited about.
I know what you want to say. And I agree, that discriminated unions are a far better idea to model the state than a simple interface. But you take two steps at the same time. You replace the initial state type or interface with a discriminated union AND you replace booleans or enums with a string. You make it seem that both go hand in hand or are the same thing, but those things are unrelated. I (and also others) suggest to only take the first step: Use DUs as the type of the state object but keep (mainly) enums for its properties:
enum Auth { authenticated; unauthenticated }
type AuthState =
| { auth: Auth.unauthenticated }
| { auth: Auth.authenticated, user: { username: string } }
Olivier already made that example in a simpler form. Then you asked what this would give one aside from more code. And my answer was: Better flexibility and code analysis. And this is why it is – how Gabriel put it – the best of both worlds.
So, yes. It is a small point. It is because we don’t fully disagree with what you say. We just want to provide a little improvement to your idea. The thing is, this small point is also the big message of the headline of this post.