const
assertions in TypeScriptEditor’s note: This article was last updated on Yan Sun on 23 July 2024 to introduce the recent release of TypeScript v5.0’s const
type parameters, briefly cover the differences between Typescript’s const
, let
, and var
declarations, and offer advanced use cases for const
assertions, such as their integration with TypeScript’s declare
keyword and how they contribute to immutability.
Introduced in Typescript 3.4, const
assertions became a valuable tool for creating type-safe code. This feature was a significant addition to the language, providing a way to make more specific and literal types in our 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. In this guide, we will explore how TypeScript’s const
assertions work and their practical applications.
const
assertions?const x = { text: "hello" } as const;
TypeScript’s official docs offer an explanation for this:
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 withconst
assertions, we can signal to the language that:– no literal types in that expression should be widened (e.g., no going from
"hello"
tostring
)
– object literals getreadonly
properties
– array literals becomereadonly
tuples
This feels a bit dry and confusing, so let’s break it down one bullet point at a time.
Type widening occurs when TypeScript automatically assigns a broader type to a variable based on its initial value. It can be surprising to encounter it for the first time due to its unexpected behavior.
Consider a variable initialized with a literal value:
const x = 'x'; // x has the type 'x'
Here, TypeScript assigns the literal type ‘x
‘ to the variable x
due to the use of const
. This prevents accidental reassignment and ensures type safety.
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 below:
let x = 'x'; // x has the type string;
Here are the two differing declarations:
const x = 'x'; // has the type 'x' let y = 'x'; // has the type string
The variable y
is assigned a wider type, string
, allowing reassignments, while the variable x
can only have the value of 'x'
.
With the new const
feature, I could also do this:
let y = 'x' as const; // y has type 'x'
However, I would expect to be marched off the premises during any good code review if I did this rather than declare y
as a const
variable, but let’s move on to point number two of the bulleted list from the docs.
readonly
propertiesBefore 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, you might recognize that the action
variable above could be used as a Redux action.
Redux, for those who don’t know, is a centralized immutable state store. The state is modified by sending actions to 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 common practice to create actions using functions called action creators. Action creators are pure functions that return Redux action object literals based on given arguments.
Below is an example. Consider a simple application requiring a global count
property. To increment the count, we’d dispatch a SET_COUNT
action. This action, with type SET_COUNT
, sets the global count
property to a new value as a literal object property.
An action creator for this action would be a function that takes a number
type argument and returns an object with a type
property of SET_COUNT
and a payload
property of type number
, which would specify the new value of count
:
const setCount = (n: number) => { return { type: 'SET_COUNT', payload: n, } } const action = setCount(3) // action has type // { type: string, payload: number }
As shown in the code above, the type
property has been widened to string
type instead of restricted to the ‘SET_COUNT
‘ literal type. This is not very type-safe; it only guarantees that the type
property is a string.
To enforce type safety before TypeScript 3.4, we would need to declare an interface
or type
for each action, but it 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 { type: 'SET_COUNT', payload: n } as const } const action = setCount(3); // action has type // { readonly type: "SET_COUNT"; readonly payload: number };
You may 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. Before 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 { type: 'SET_COUNT', payload: n } as const } const resetCount = () => { return { type: 'RESET_COUNT' } as const } type CountActions = ReturnType<typeof setCount> | ReturnType<typeof resetCount>; // type CountActions = { // readonly type: "SET_COUNT"; // readonly payload: number; // } | { // readonly type: "RESET_COUNT"; // }
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 open for modification. With const
, we can lock the literals to their explicit values, and prevent 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
The above example shows that the payload
array could be modified after creation.
If we apply const
to the object literal, then we tighten everything up nicely:
const action = { type: 'SET_HOURS', payload: [8, 12, 5, 8] } as const // { // 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 official 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 we declare a variable using the const
keyword in TypeScript, we 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 specific type that represents a specific value rather than a general type that represents a range of values. By using a const
assertion, we can specify that a value should be treated as a specific literal type rather than inferred as a more general type.
For example, if we have a variable x
with a value of 10
, TypeScript will infer the type of x
as a number by default. However, if we use a const
assertion, like y = 10 as const
, TypeScript will infer the type of y
as 10, which is a literal type.
Here, we declare a constant string greeting
using the const
keyword. Because greeting
is a named constant variable, we cannot reassign it to a different value later in the code:
const greeting = 'hello'; // const declaration greeting = 'world'; // Error: Cannot assign to 'myString' because it is a constant.
In the first example below, myArray
is a regular array, and its content can be modified. Thus we can use the push
method to add an element to the array successfully.
In the second example, myArrayLiteral
is declared as a const
assertion, creating a read-only array with specific values. When we try to modify it with push
, we get a TypeScript error. This is because myArrayLiteral
has a type of readonly [1,2,3]
, thus any attempt to change it will be caught by the compiler:
// Without const assertion const myArray = [1, 2, 3]; // number[] myArray.push(4); // This is allowed, modifying the array // With const assertion const myArrayLiteral = [1, 2, 3] as const; // readonly [1, 2, 3] myArrayLiteral.push(4); // Error: Property 'push' does not exist on type 'readonly [1, 2, 3]'.
const
, let
, and var
declarationTypeScript inherits the same variable declaration keywords from JavaScript: var
, let
, and const
. Although they serve similar purposes, their behaviors vary significantly.
The var
keyword has been part of JavaScript from the beginning. Variables declared with var
are function-scoped. This means the var
variables are accessible throughout the function.
Introduced in ES6, the let
keyword provides block-level scope, making variables only accessible within the block they are declared. This helps prevent issues with variable shadowing and improves code clarity.
Like let
, const
also offers block-level scope. However, variables declared with const
are immutable, meaning their values cannot be changed after initialization.
The below example demonstrates the key differences between var
, let
, and const
keywords in JavaScript:
function declarationTest() { var x = 10; // Function-scoped variable, can be reassigned let y = 20; // Block-scoped variable, cannot be reassigned within the same block const z = 30; // Block-scoped constant, cannot be reassigned if (true) { var x = 100; // Reassigns the outer x due to function scope let y = 200; // Creates a new y within the block const z = 300; // Creates a new z within the block console.log(x, y, z); // Output: 100 200 300 } console.log(x, y, z); // Output: 100 20 30 }
The first console.log
outputs 100
, 200
, and 300
because the variables are accessed within the if
block where they were declared or reassigned.
The second console.log
outputs 100
, 20
, and 30
because x
is function-scoped and retains its value after the if
block, while y
and z
are block-scoped and their values are not accessible outside their respective blocks.
Modern JavaScript engines have optimized the performance differences between var
, let
, and const
to the point where the performance differences are negligible. It’s more important to focus on code readability, and avoiding unexpected behavior than on micro-optimizations.
const
assertionsWhile the basic use of const
assertions is straightforward, its applications extend beyond simple type narrowing.
declare
keywordThe declare
keyword defines the variables, or functions expected to exist at runtime, to provide type information for existing JavaScript libraries. We can use the declare
keyword together with const
to indicate that a constant exists without actually defining it in the code. This is often used for global constants or shared values across modules:
declare const BASE_URL: string;
In this example, BASE_URL
is declared as a constant of type string
. The declare
keyword indicates that API_URL
is defined elsewhere. This allows the compiler to type-check its usage without having a direct definition in the code.
const
assertions in state managementIn state management libraries like Redux, managing complex states with deeply nested objects can be challenging. Using const
assertions helps to define a fixed structure for the state, ensuring consistency and preventing unintentional changes:
const initialState = { user: { name: "John", roles: ["admin", "user"], }, settings: { theme: "dark", notifications: true, }, } as const; // The type of initialState is: // const initialState: { // readonly user: { // readonly name: "John"; // readonly roles: readonly ["admin", "user"]; // }; // readonly settings: { // readonly theme: "dark"; // readonly notifications: true; // }; //}
In the above example, we use as const
to preserve the structure of initialState
, and all nested properties are marked as readonly
. This ensures that reducers or other code cannot accidentally mutate the state directly, promoting a more predictable state management pattern.
const
assertions and immutabilityWhile const
assertions don’t enforce runtime immutability, they contribute to a mindset of immutability. Consistently using const
assertions helps establish a pattern of immutability, enabling compile-time type checking, and making the intent clearer. This practice can reduce the risk of unintended side effects, as it signals to developers that certain values should not be modified.
const
type parametersTypeScript 5.0 introduced const
type parameters, enhancing type inference for object literals. Before TypeScript 5.0, TypeScript typically selected a general type when inferring the type of an object. In the example below, the inferred type of roleNames
is string[]
:
type Roles = {readonly roleNames: string[] } function getRoles<T extends Roles>(arg: T): T["roleNames"]{ return arg.roleNames } // const roleNames: string[] const roleNames = getRoles({roleNames: ['admin', 'developer', 'tester']})
However, we may want to have more specific types inferred for the roleNames
type in this example. With the new const
type parameters feature, TypeScript will infer the most specific type for the argument:
type Roles = {readonly roleNames: string[] } function getRoles<const T extends Roles>(arg: T): T["roleNames"]{ return arg.roleNames } // const roleNames: ["admin", "developer", "tester"] const roleNames = getRoles({roleNames: ['admin', 'developer', 'tester']})
In line 2 of the above example, we add a const
modifier to the type parameter declaration to enable const-like inference by default. In this case, roleNames
is inferred as ["admin", "developer", "tester"]
instead of a generic string[]
, providing more precise type information for subsequent operations.
const
assertions are a TypeScript feature that allows 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.
Would you be interested in joining LogRocket's developer community?
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 […]
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!