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:
- Omit types that are only useful for hinting immutability to the compiler
- Skip over type assertions
- Be used in place of an enum (if one is scared of the magic used to transpile enums into JS)
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.