Paul Cowan Contract software developer

const assertions are the killer new TypeScript feature

4 min read 1348

TypeScript Logo

Typescript 3.4 is out and while I find the official TypeScript docs very informative, they always feel a little bit too academic and a touch dry. When I am finding out about new features, I want to know the exact ailment this feature is going to cure.

For my money, const assertions are the killer new feature of TypeScript 3.4 and as I will explain later, I can omit a lot of tedious type declarations using this new feature.

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 us break this down one bullet point at a time.

No type widening for literal types

Not everyone knows what type widening is and it came as quite a surprise when first discovered due to some 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 this:

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 that 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 us move swiftly on to point number two of the bullet 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 a const, the type property can still be reassigned and, as such, the property is widened to a string type.

This still does not feel that useful, so let us use a better example.

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 do not know, is a global immutable state store. The state is modified by sending actions to what are called reducers. Reducers are pure functions that return a new updated version of the global state after every action is dispatched that reflects the modifications specified in the action.

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

This is not very good, if we want to take advantage of discriminated unions that narrow on the type property then prior to TypeScript 3.4 we would need to declare an interface or type for each action:

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

This really adds to the burden of writing Redux actions and reducers but we can cure this 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 };

The observant 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 which 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.

The 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 constReturnType 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 has made the literal properties of the above action more generic because they are 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 has 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).

Conclusion

I can really sum up all of the above up with this code example:

let obj = {
  x: 10,
  y: [20, 30],
  z: {
    a:
      {  b: 42 }
  } 
} as const;

corresponds to:

let obj: {
  readonly x: 10;
  readonly y: readonly [20, 30];
  readonly z: {
    readonly a: {
      readonly b: 42;
    };
  };
};

Here, I can infer the type rather than writing excess boilerplate typing. This is especially useful with redux where discriminated unions are used to narrow the type on object literal expressions.

Plug: , a DVR for web apps

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

.
Paul Cowan Contract software developer

2 Replies to “const assertions are the killer new TypeScript feature”

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

Leave a Reply