Using TypeScript const assertions for fun and profit

29 April 2021

How can we utilise the const assertion in TypeScript to flip the script and define types from immutable data?

The const assertion

First introduced in TypeScript 3.4, the as const notation is used to mark a literal as a const assertion. The syntax is used to signal one of 3 things to the compiler:

  • An object's properties are readonly
  • An array of literals is readonly
  • A literal type cannot be widened, e.g. from "baz" to string.

Here are examples of each:

//-----------------------------------
// Example 1: mark an object's properties as readonly
//
// 'x' is of type '{ readonly bar: "baz" }'
//-----------------------------------
let x = {bar: "baz"} as const;

// Compiler error: Cannot assign to 'bar' because it is a read-only property
x.bar = "error"; 
//-----------------------------------
// Example 2: mark an array's of literals as `readonly`
// 
// 'y' is of type 'readonly [1, 2, 3]'
//-----------------------------------
let y = [1, 2, 3] as const;

// Compiler error: Cannot assign to '0' because it is a read-only property.
y[0] = 23;
//-----------------------------------
// Example 3: narrowing variable's type to a literal type
// 
// 'z' is of type 'hello'
//-----------------------------------
let z = "hello" as const

// compiler error: Type '"goodbye"' is not assignable to type '"hello"'.
z = "goodbye"; 

Example 1 defines a const value x with a single readonly property, bar, of type "baz".

Yes, you read that right, its type is "baz", not string. This is an example of a literal type: a more concrete, narrower sub-type of a collective type, such as a string. Example 3 is another example, where z is type "hello". To paraphrase the documentation,"baz" is a type of string but a string is not a type of "baz".

Defining Union Types from Immutable Data

The const assertion documentation, cites practical examples of how to used this feature, including the ability to:

All great ways to use const assertions in your code, but I believe I have found another nifty use for them: defining union types from immutable data. This can be used to make the usages of such data more type-safe without additional maintenance overhead.

Let me explain through a worked example... Say I am developing an API that uses a well-defined set of cars, such that they only represent several manufacturers. I could start with something that looks like this:

interface Car {
  manufacturer: string;
  model: string;
  bodyStyle: string;
}

const CARS: Car[] = [
  {
    manufacturer: "AUDI",
    model: "A1",
    bodyStyle: "Hatchback"
  },
  {
    manufacturer: "AUDI",
    model: "A5",
    bodyStyle: "Coupe"
  },
  {
    manufacturer: "BMW",
    model: "3 Series",
    bodyStyle: "Sedan"
  },
  {
    manufacturer: "BMW",
    model: "X1",
    bodyStyle: "SUV"
  },
];

All looks fine here at first glance, and such an interface should do the trick. However, since the type for manufacturer is string, and I know that the data only represents Audis and BMWs, then I’m leaving some type safety on the table.

Since string is a type that is too general for the data, I may decide to leverage discriminated union types to restrict what values manufacturer can have, preventing me from creating a Car that isn’t either an Audi or a BMW:

type Manufacturer = "AUDI" | "BMW";

interface Car {
  manufacturer: Manufacturer;
  model: string;
  bodyStyle: string;
}

Nice! Now my manufacturer field is type-safe, and it can only take the value "AUDI" | "BMW".

But what if my data requires changes, and a new manufacturer is added? For instance, if I have to add a Mercedes to the set of cars:

const CARS: Car[] = [
  ...,
  {
    manufacturer: "MERCEDES", // Compiler error! Expected '"AUDI" | "BMW"' but got "MERCEDES"
    model: "A-Class",
    bodyStyle: "Hatchback"
  },
];

Well, the TypeScript compiler should complain here, which is a great start - however, now I have to change my Manufacturer type by hand to make sure it is up-to-date. Thus becoming yet another thing for me to maintain!

What if there was another way? Perhaps leveraging the compiler to create the Manufacturer type for me? 🤔

Using the const assertion to create a type-safe interface

Let’s make a small change to the way we define the CARS array, adding the as const notation and removing the Car interface:

const CARS = [
  {
    manufacturer: "AUDI",
    model: "A1",
    bodyStyle: "Hatchback"
  },
  {
    manufacturer: "AUDI",
    model: "A5",
    bodyStyle: "Coupe"
  },
  {
    manufacturer: "BMW",
    model: "3 Series",
    bodyStyle: "Sedan"
  },
  {
    manufacturer: "BMW",
    model: "X1",
    bodyStyle: "SUV"
  },
  {
    manufacturer: "MERCEDES",
    model: "A-Class",
    bodyStyle: "Hatchback"
  },  
] as const;

Now, let's create a type Car that can only match the cars in this array. We do this by declaring it to be of a type only found found in the array, using typeof CARS[number]. This simple trick will prevent users from creating a type Car not in the array.

type Car = typeof CARS[number];

// Compiler error! This "newCar" literal doesn't match any element of CARS.
const newCar: Car = {
  manufacturer: "VOLKSWAGEN",
  model: "Passat",
  bodyStyle: "Sedan"
};

Furthermore, I can access individual properties of each car in the data in order to create further utility types from their values.

// "AUDI" | "BMW" | "MERCEDES"
type Manufacturer = typeof CARS[number]["manufacturer"]; 

// "A1" | "A5" | "3 Series" | "X1" | "A-Class"
type Model = typeof CARS[number]["model"];

// "Hatchback" | "Coupe" | "Sedan" | "SUV"
type BodyStyle = typeof CARS[number]["bodyStyle"];

This is great. Now the Manufacturer type is maintained for me by the compiler. As I add and remove cars from the CARS array over time, I no longer need to retrospectively update the types associated with them.

Now, if I were to define a function that takes a parameter of type Manufacturer, or declare a literal of type Manufacturer, I cannot pass it anything other than "AUDI" | "BMW" | "MERCEDES".

// Compiler error! Expected '"AUDI" | "BMW" | "MERCEDES"' but got "bar"
const foo: Manufacturer = "bar"; 

Equally, if I were to remove the as const notation from the CARS declaration, then the compiler will widen the type Manufacturer to a string, and we’d be back to square one:

const CARS = [
  {
    manufacturer: "AUDI",
    model: "A1",
    bodyStyle: "Hatchback"
  },
  {
    manufacturer: "AUDI",
    model: "A5",
    bodyStyle: "Coupe"
  },
  {
    manufacturer: "BMW",
    model: "3 Series",
    bodyStyle: "Sedan"
  },
  {
    manufacturer: "BMW",
    model: "X1",
    bodyStyle: "SUV"
  },
  {
    manufacturer: "MERCEDES",
    model: "A-Class",
    bodyStyle: "Hatchback"
  },
];

// Manufacturer is of type "string"
type Manufacturer = typeof CARS[number]["manufacturer"];
const foo: Manufacturer = "bar";  // OK!!

Summary and Additional Reading

In this example, we were able to use the const assertion in order to "flip the script"; leveraging the compiler to define a set of utility types from the data, rather than having to do things the other way around. It proved useful as we can get the benefits of being able to use the data in a more type-safe way, without the additional maintenance.

If you’d like to learn more, then you should check out these crazy, powerful TypeScript 4.1 features, some TypeScript testing tips, and this talk introducing an entirely different meaning to "TDD": Type Driven Development

Lastly, if you found all of this TypeScript magic interesting and want to learn more still, have a look at our Accelerated TypeScript course.

Article By
blog author

Ross Jenkins

Software Engineer