Everything You Need to Know About Const Assertions in TypeScript

Learn about Const assertions in TypeScript. Learn how to use this powerful feature to create more robust and type-safe code. const assertions are a powerful TypeScript tool that create more type-safe code while reducing boilerplate.

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:

  • What are const assertions?
  • No type widening for literal types
  • Object literals get readonly properties
  • Array literals become readonly tuples
  • The difference between const declarations and assertions

What are const 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.

No type widening for literal types

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.

Object literals get readonly properties

Prior 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.

Array literals become readonly tuples

Before 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).

The difference between const declarations and assertions

In 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.

Conclusion

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.

Source: https://blog.logrocket.com

#typescript

Everything You Need to Know About Const Assertions in TypeScript
1.10 GEEK