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?

Since TypeScript 3.4, we’ve been able to use the as const notation for creating “const assertions” on our literals. This is used to signal to the compiler that any object properties of the literal are readonly, any array literals are readonly, and lastly that any literal types used cannot be widened.

For example:

const foo = {bar: "baz"} as const;

Means that the object foo has a single property bar such that its value is of type ”baz”. This is also an example of a killer feature of TypeScript known as literal types, whereby specific instances of a string or number can be used as a type.

If you look at the documentation for const assertions, they cite several examples of how this feature can be useful, including the ability to:

Defining union types from immutable data

All the above examples are great ways to utilise the const assertion in your code, but I think I’ve found another really nifty use for them: defining union types from immutable data. This can be used in order to make the usages of such data more type-safe, without the additional maintenance overhead!

Let me explain through a worked example...

Defining a type-safe interface

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;

Firstly, I can create a type to match only the cars that exist in my data. We can do so by using their index in the CARS array, and the typeof operator. This can be useful to ensure that I cannot create a car that does not exist in the data.

type Car = typeof CARS[number];
// { ... } | { ... } | { ... } | { ... } | { ... }

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

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

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

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

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

This is great! Now the Manufacturer type is maintained for me by the compiler. As I add and remove cars from the data over time, I no longer need to respectively 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”.

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

If I were to define the same array of cars as above, omitting the const declaration, then the compiler will not be able to rely on this object being immutable. As a result, the type Manufacturer would be widened to 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"
  },
];

type Manufacturer = typeof CARS[number]["manufacturer"];
// "string"

Summary and extra reading

As TypeScript users, there are loads of cool ways we can leverage the compiler. Both my colleagues at Instil & myself advocate for tinkering around to see what interesting stuff you can uncover.

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, then definitely have a look at our Accelerated TypeScript course.

Article By
blog author

Ross Jenkins

Software Engineer