const
assertions in TypeScriptEditor’s note: This article was last updated on 9 March 2023 to add a section about the difference between const
declarations and assertions.
Since their inception in Typescript 3.4, const
assertions have remained a powerful tool for creating more precise and type-safe code. This feature was a significant addition to the language, providing a way to make more specific and literal types in your code.
With const
assertions, developers can provide an explicit type annotation to ensure that a value is treated as a literal type, rather than being widened to a more general type.
This article will cover:
const
assertions?readonly
propertiesreadonly
tuplesconst
declarations and assertionsconst
assertions?const x = { text: "hello" } as const;
The official docs give this explanation:
TypeScript 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
This feels a bit dry and a little confusing. Let’s break this down one bullet point at a time.
Individuals may need to become more familiar with type widening. It can be surprising to encounter it for the first time due to its unexpected behavior.
When we declare a literal variable using the keyword const
, then the type is the literal on the right-hand side of the equals sign. For example:
const x = 'x'; // x has the type 'x'
The const
keyword ensures that no reassignment to the variable can happen, and a strict type of only that literal is guaranteed.
But if we use let
instead of const
, then we are leaving that variable open to reassignment, and the type is widened to the literal’s type like so:
let x = 'x'; // x has the type string;
Below are the two differing declarations:
const x = 'x'; // has the type 'x' let y = 'x'; // has the type string
y
is widened to a more general type, which will allow it to be reassigned to other values of that type, and x
can only ever have the value of 'x'
.
With the new const
feature, I could do this:
let y = 'x' as const; // y has type 'x'
I would expect to be marched off the premises during any good code review if I did the above rather than simply declaring y
as a const
variable, but let’s move swiftly to point number two of the bulleted list from the docs.
readonly
propertiesPrior to Typescript 3.4, type widening happened across the board with object literals:
const action = { type: 'INCREMENT', } // has type { type: string }
Even though we have declared action
as const
, the type
property can still be reassigned and, as such, the property is widened to a string type. If you are familiar with Redux, then you might recognize that the action
variable above could be used as a Redux action.
Redux, for those who don’t know, is a global immutable state store. The state is modified by sending actions to what are called reducers. Reducers are pure functions that take the current state of an application, perform an action, and return a new state..
In Redux, it is standard practice to create your actions from functions called action creators. Action creators are simply pure functions that return Redux action object literals in conjunction with any arguments that are supplied to the function.
This is better illustrated with an example. An application might need a global count
property, and in order to update this count
property, we could dispatch actions of type SET_COUNT
that simply set the global count
property to a new value, which is a literal object property.
An action creator for this action would be a function that takes a number as an argument and returns an object with a type
property of SET_COUNT
and a payload property of type number
, which would specify what the new value of count
is:
const setCount = (n: number) => { return { type: 'SET_COUNT', payload: n, } } const action = setCount(3) // action has type // { type: string, payload: number }
As you can see from the code shown above, the type
property has been widened to string
and not SET_COUNT
. This is not very type-safe; all we can guarantee is that the type
property is a string. Every action in Redux has a type
property, which is a string.
Prior to TypeScript 3.4, we would need to declare an interface
or type
for each action, but it really adds to the burden of writing Redux actions and reducers:
interface SetCount { type: 'SET_COUNT'; payload: number; } const setCount = (n: number): SetCount => { return { type: 'SET_COUNT', payload: n, } } const action = setCount(3) // action has type SetCount
The code above can be refactored by adding a const
assertion:
const setCount = (n: number) => { return <const>{ type: 'SET_COUNT', payload: n } } const action = setCount(3); // action has type // { readonly type: "SET_COUNT"; readonly payload: number };
Some of you will have noticed that the type inferred from setCount
has had the readonly
modifier appended to each property, as stated in the bullet point from the docs.
That is exactly what has happened:
{ readonly type: "SET_COUNT"; readonly payload: number };
Each literal in the action has had the readonly
modifier added.
In Redux, we build up a union of allowed actions that a reducer function can take to get good type safety around the actions we are dispatching. Prior to TypeScript 3.4, we would do this:
interface SetCount { type: 'SET_COUNT'; payload: number; } interface ResetCount { type: 'RESET_COUNT'; } const setCount = (n: number): SetCount => { return { type: 'SET_COUNT', payload: n, } } const resetCount = (): ResetCount => { return { type: 'RESET_COUNT', } } type CountActions = SetCount | ResetCount
We have created two interfaces, RESET_COUNT
and SET_COUNT
, to type the return types of the two action creators resetCount
and setCount
. CountActions
is a union of these two interfaces.
With const
assertions, we can remove the need for declaring all of these interfaces by using a combination of const
, ReturnType
, and typeof
:
const setCount = (n: number) => { return <const>{ type: 'SET_COUNT', payload: n } } const resetCount = () => { return <const>{ type: 'RESET_COUNT' } } type CountActions = ReturnType<typeof setCount> | ReturnType<typeof resetCount>;
We have a nice union of actions inferred from the return types of the action creator functions setCount
and resetCount
.
readonly
tuplesBefore TypeScript 3.4 declaring an array of literals would be widened and was open for modification. With const
, we can lock the literals to their explicit values and also disallow modifications.
If we had a Redux action type for setting an array of hours, it might look something like this:
const action = { type: 'SET_HOURS', payload: [8, 12, 5, 8], } // { type: string; payload: number[]; } action.payload.push(12) // no error
Prior to TypeScript 3.4, widening made the literal properties of the above action more generic because they were open for modification.
If we apply const
to the object literal, then we tighten everything up nicely:
const action = <const>{ type: 'SET_HOURS', payload: [8, 12, 5, 8] } // { // readonly type: "SET_HOURS"; // readonly payload: readonly [8, 12, 5, 8]; // } action.payload.push(12); // error - Property 'push' does not exist on type 'readonly [8, 12, 5, 8]'.
What has happened here is exactly what the bullet point from the docs stated: the payload number array is indeed a readonly
tuple of [8, 12, 5, 8]
(but I certainly did not get this from reading the docs).
const
declarations and assertionsIn TypeScript, const
declarations and const
assertions serve different purposes. const
declarations create a named constant variable, while const
assertions create literal types.
When you declare a variable using the const
keyword in TypeScript, you create a named constant variable whose value cannot be reassigned.
On the other hand, const
assertions are used to create literal types in TypeScript. A literal type is a more specific type that represents a specific value rather than a general type that represents a range of values. By using a const
assertion, you can specify that a value should be treated as a specific literal type rather than being inferred as a more general type.
For example, if you have a variable x
with a value of 10, TypeScript will infer the type of x
as a number by default. However, if you use a const
assertion, like y = 10 as const
, TypeScript will infer the type of y
as 10, which is a more specific and literal type.
In the following code, we declare a constant string greeting
using the const
keyword. Since greeting
is a named constant variable, we cannot reassign it to a different value later on in the code:
const greeting = 'hello'; // const declaration greeting = 'world'; // Error: Cannot assign to 'myString' because it is a constant.
We declare a logNumber()
in the below code, which takes a number argument and prints it on the console:
function logNumber(num: number) { console.log(num); } const num = 10; // const declaration const ten = 10 as const; // const assertion logNumber(num); // Compiles successfully, since num is of type 'number' logNumber(ten); // Error: Argument of type '10' is not assignable to parameter of type 'number'
Next, we declare two constants, num
and ten
. num
is a regular constant variable that holds a numeric value, while ten
is declared using a const
assertion — as const
— which ensures that its type is a literal number 10
.
When we try to pass ten
to the logNumber
function, we get a TypeScript error. This is because ten
has a literal type of 10
, which is not compatible with the expected type of number
.
const
assertions are a feature in TypeScript that allow developers to create variables with literal types that cannot be widened. const
assertions also help reduce boilerplate code, making it easier to write and maintain complex applications. By using const
assertions, developers can ensure that the correct values are used throughout the application, catching errors at compile time and avoiding runtime bugs.
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.
ElectricSQL is a cool piece of software with immense potential. It gives developers the ability to build a true local-first application.
Leptos is an amazing Rust web frontend framework that makes it easier to build scalable, performant apps with beautiful, declarative UIs.
Learn more about the 5 best JavaScript libraries for dealing with multidimensional arrays, such as ndarray, math.js, and NumJs.
We spoke with Dom about his approach to balancing innovation with handling tech debt and to learn how he stays current with technology.
5 Replies to "A complete guide to <code>const</code> assertions in TypeScript"
The example in your conclusion is wrong: z and a would not be read-only since those are the keys for nested object. This is currently the behavior of “as const” syntax.
When will babel-plugin-transform-typescript support?
(https://github.com/babel/babel/tree/master/packages/babel-plugin-transform-typescript)
that isn’t true, this is the resultant type:
“`
let obj: {
readonly x: 10;
readonly y: readonly [20, 30];
readonly z: {
readonly a: {
readonly b: 42;
};
};
}
“`
and this error happens when you try to modify z o a
“`
Cannot assign to ‘z’ because it is a read-only property.(2540)
“`
Great article. Saved me from complex wiring.
The example with redux actions is striking. With interfaces it’s clear and reads nicely, with ‘const’ assertion, it becomes more…implicit and easier to overlook. IMO interfaces are better for this purpose. The goal is not to write maintainable code, not as little code as possible.
But the purpose of the assertion is clear when it comes to literals.
Nice article, thanks!