2020-08-05
|~5 min read
|914 words
I was discussing a refactor for some types recently with a colleague when he suggested I use a const
assertion. There are several good articles on the web already about the value in the const
assertion, such as this one from LogRocket, but it took a little playing around with it myself before it really clicked.
Specifically, it took comparing it to Type Guards, an alternative approach to get the type safety, and one I’d written about previously related to using useReducer
.
Below, I’ll walk through an example, first without Type Guards or const
assertions, then looking at each approach in turn. From the final result, it’s clear that the const
assertion is significantly less code while providing the same benefits in a format that’s easier to reason through.
In my particular case, we were looking at Redux Actions and Reducers, so that’s going to be the basis for my example - though certainly Redux is not necessary and if the concept is new - feel free to imagine all of these as regular functions (which is what they are after all).
const enum ActionTypes {
Login = "Login",
SetAge = "SetAge",
}
function login(username: string) {
return {
type: ActionTypes.Login,
payload: { username },
}
}
function setAge(age: number) {
return {
type: ActionTypes.SetAge,
payload: { age },
}
}
type Actions = ReturnType<typeof login> | ReturnType<typeof setAge>
function reducer(action: Actions) {
console.log(action.payload.age) // typescript complains because it cannot determine if `age` is available
switch (action.type) {
case ActionTypes.Login:
console.log(action.payload.username) // typescript complains because it cannot determine if `username` is available
break
case ActionTypes.SetAge:
console.log(action.payload.age) // typescript complains because it cannot determine if `age` is available
break
}
}
In this first iteration, Typescript will complain loudly when it gets to the reducer
because it’s unable to determine which Action type it is (though Typescript is aware that each case is a type: Actions
, it’s not specific).
One solution here is to use a Type Guard, though it’s a verbose solution. To get them to work, we need to provide not only the Type Guard functions themselves, but also interfaces for each of the Actions:
const enum ActionTypes {
Login = "Login",
SetAge = "SetAge",
}
function login(username: string) {
return {
type: ActionTypes.Login,
payload: { username },
}
}
function setAge(age: number) {
return {
type: ActionTypes.SetAge,
payload: { age },
}
}
export interface Action {
type: ActionTypes
}
export interface Login extends Action {
payload: { username: string }
}
export interface SetAge extends Action {
payload: { age: number }
}
// The Type Guard Functions
function isLogin(action: Action): action is Login {
return action.type === "Login"
}
function isSetAge(action: Action): action is SetAge {
return action.type === "SetAge"
}
Then, to make use of these Type Guards, we would refactor the reducer and replace the switch
statement with an if/else
chain:
//...
function reducer( action: Actions ) {
console.log(action.payload.age); // typescript complains because it cannot determine if `age` is available
- switch (action.type) {
- case ActionTypes.Login:
- console.log(action.payload.username); // typescript complains because it cannot determine if `username` is available
- break;
- case ActionTypes.SetAge:
- console.log(action.payload.age); // typescript complains because it cannot determine if `age` is available
- break;
+ if(isLogin(action)){
+ console.log(action.payload.username)
+ }
+ if(isSetAge(action)){
+ console.log(action.payload.age)
}
}
This works - but it’s a lot more code, requiring a Type Guard for each type. This can get overwhelming, particularly if there are dozens or hundreds of elements in the enum - which in a large application is not unheard of.
const
AssertionA simpler alternative is to cast the Action
as a const
which prevents a widening of the type.
From the release notes for Typescript 3.4 in which const assertions were added:
const
assertionsTypeScript 3.4 introduces a new construct for literal values called const assertions. Its syntax is a type assertion with const in place of the type name (e.g. 123 as const). When we construct new literal expressions with const assertions, we can signal to the language that
- no literal types in that expression should be widened (e.g. no going from “hello” to string)
- object literals get readonly properties
- array literals become readonly tuples
What this really means for our purposes, is that we can eliminate all of the boiler plate around providing interfaces for the Actions
or defining the Type Guards and still receive the type safety from Typescript:
const enum ActionTypes {
Login = "Login",
SetAge = "SetAge",
}
function login(username: string) {
return {
type: ActionTypes.Login,
payload: { username },
} as const
}
function setAge(age: number) {
return {
type: ActionTypes.SetAge,
payload: { age },
} as const
}
type Actions = ReturnType<typeof login> | ReturnType<typeof setAge>
function reducer(action: Actions) {
console.log(action.payload.age) // Typescript still complains here because it does not know the action type
switch (action.type) {
case ActionTypes.Login:
console.log(action.payload.username) // Typescript knows the Action type is login, therefore there will be `username` on the payload
break
case ActionTypes.SetAge:
console.log(action.payload.age) // Typescript knows the Action type is SetAge, therefore there will be `age` on the payload
break
}
}
By restricting the types to its literal format or read only for arrays and objects, the const
communicates to Typescript the strict type shape allowing access to type specific properties with confidence and safety. Hooray!
Hi there and thanks for reading! My name's Stephen. I live in Chicago with my wife, Kate, and dog, Finn. Want more? See about and get in touch!