The power of const assertions in TypeScript

TypeScript 3.4 introduced const assertions which allow you to aid typescript in infering concrete types for your literals. This is a powerful feature that can help your application's type-system grow without having to maintain explicit interfaces and types! Think of it as a way to let TypeScript do the heavy lifting for you.

How this helps: This allows the developer to focus on the implementation and let TypeScript infer "narrower" types from the implementation.

What is this const assertion you speak of?

Let's set the stage: I would confidently say that it is a common problem we deal with as developers is changing object shapes. Whether it is transforming, seralizing, mapping, etc, we are always taking an input and making new outputs. We also take specific values, create types for them, an enum or object of key-value pairs, and it can be tedious to maintain them.

Consider this basic example of an object to define a configuration of typography:

const FONT_SIZES = {
  small: "0.75rem",
  medium: "1rem",
  large: "1.25rem",
};
// const FONT_SIZES: {
//   small: string;
//   medium: string;
//   large: string;
// }

// An accessory type for the t-shirt sizes
type FontSizeTShirt = keyof typeof FONT_SIZES;
// small, medium, large

// An accessory type for the "values"
type FontSize = typeof FONT_SIZES[FontSizeTShirt];
// string

What makes this a big deal?

FONT_SIZES is mapping t-shirt sizes (of maybe a design system) to their respective font sizes. We have a clear definition of what the object looks like, But TypeScript doesn't know that FONT_SIZES is a static object that will not change. Because of this, FontSize is forced to be "wide" to allow for compliance with the TypeScript compiler, string. This is where const assertions come in! If we add as const to the end of the FONT_SIZES definition, TypeScript will infer the object as a literal object and not a mutable object.

const FONT_SIZES = {
  small: "0.75rem",
  medium: "1rem",
  large: "1.25rem",
} as const;

// const FONT_SIZES: {
//   readonly small: "12px";
//   readonly medium: "16px";
//   readonly large: "24px";
// }

// An accessory type for the t-shirt sizes
type FontSizeTShirt = keyof typeof FONT_SIZES; // small, medium, large


// An accessory type for the "values"
type FontSize = typeof FONT_SIZES[FontSizeTShirt]; // "0.75rem" | "1rem" | "1.25rem"

Woohoo! Leveraging const assertions we have now told TypeScript that FONT_SIZES is a static object and it will not change, and now we can use TypeScript and it's utility types to have narrower types to be used in our application.

Why is this so powerful? This allows a function, and object... the code!... to drive the type-system in your application. When you think about it, it makes complete sense. We want our types to keep us aligned with our code. If we can leverage the code to drive the types, we can focus on the implementation and let TypeScript do the heavy lifting. The other benefit is refactoring: it allows the developer to make the code changes, and the types will automatically follow.

Where can I use this? There are many places where this might be valueable. But these are the main ones I typically run into with my projects:

  • fetched AJAX data (FYI response.json() is type Promise<any>!)
  • config-like objects for builds, themes, static data
  • mapping objects to new structures (serialization, transformation, etc.)

If we as developers can create our initial types from our "entry points" for data and then implement functions that can "declare" types from its output, it takes the maintenance effort from the developer and allows us to focus on the implementation. It allows our type-system to naturally cascade in the codebase.

The real hidden treat, it gives developers breadcrumbs to the stack of what functions get you to your result. No more having to play follow the interface through your codebase to see where that object is created. The interface ties directly to the function that makes it!

Ok, that was a lot. What I really want to get across is that the biggest complaint I hear about TypeScript is managing the types. And honestly, I get that. But, understanding and leveraging TypeScript features like const assertions, you can let the language do the heavy lifting and use the built-in tools to pull those out to reusable types!

As amazing as feature is, there are a few things to keep in mind:

It's not a silver bullet const assertions won't work everywhere, and this tool doesn't mean you won't ever have to make TypeScript types.

  • Recusive types can't be inferred in typescript, and will need definitions
  • If you are dynamically setting a variable and the values are not const themselves, it will resolve to the widest accepted type
  • const assertions do NOT mean immutability. TypeScript sets the readonly type to get the compiler to scream at you if you try to mutate it. But it doesn't mean you can't "get around" that.

TypeScript has some pretty amazing features to take the pressure off the "admin" management around types. I always recommend bookmarking the TypeScript Utility Types page as the language has so many helpful types to handle the definitions for you! Hopefully I have persuaded you to try out const assertions, and I wish you luck in your TypeScript adventure!