Marius Schulz
Marius Schulz
Front End Engineer

Const Assertions in Literal Expressions in TypeScript

With TypeScript 3.4, const assertions were added to the language. A const assertion is a special kind of type assertion in which the const keyword is used instead of a type name. In this post, I'll explain how const assertions work and why we might want to use them.

Motivation for const Assertions

Let's say we've written the following fetchJSON function. It accepts a URL and an HTTP request method, uses the browser's Fetch API to make a GET or POST request to that URL, and deserializes the response as JSON:

function fetchJSON(url: string, method: "GET" | "POST") {
  return fetch(url, { method })
    .then(response => response.json());
}

We can call this function and pass an arbitrary URL to the url param and the string "GET" to the method param. Note that we're using two string literals here:

// OK, no type error
fetchJSON("https://example.com/", "GET")
  .then(data => {
    // ...
  });

To verify whether this function call is type-correct, TypeScript will check the types of all arguments of the function call against the parameter types defined in the function declaration. In this case, the types of both arguments are assignable to the parameter types, and therefore this function call type-checks correctly.

Let's now do a little bit of refactoring. The HTTP specification defines various additional request methods such as DELETE, HEAD, PUT, and others. We can define an HTTPRequestMethod enum-style mapping object and list the various request methods:

const HTTPRequestMethod = {
  CONNECT: "CONNECT",
  DELETE: "DELETE",
  GET: "GET",
  HEAD: "HEAD",
  OPTIONS: "OPTIONS",
  PATCH: "PATCH",
  POST: "POST",
  PUT: "PUT",
  TRACE: "TRACE"
};

Now we can replace the string literal "GET" in our fetchJSON function call by HTTPRequestMethod.GET:

fetchJSON("https://example.com/", HTTPRequestMethod.GET)
  .then(data => {
    // ...
  });

But now, TypeScript produces a type error! The type checker points out that the type of HTTPRequestMethod.GET is not assignable to the type of the method param:

// Error: Argument of type 'string' is not assignable
// to parameter of type '"GET" | "POST"'.

Why is that? HTTPRequestMethod.GET evaluates to the string "GET", the same value that we passed as an argument before. What's the difference between the types of the property HTTPRequestMethod.GET and the string literal "GET"? To answer that question, we have to understand how string literal types work and how TypeScript performs literal type widening.

String Literal Types

Let's look at the type of the value "GET" when we assign it to a variable declared using the const keyword:

// Type: "GET"
const httpRequestMethod = "GET";

TypeScript infers the type "GET" for our httpRequestMethod variable. "GET" is what's called a string literal type. Each literal type describes precisely one value, e.g. a specific string, number, boolean value, or enum member. In our case, we're dealing with the string value "GET", so our literal type is the string literal type "GET".

Notice that we've declared the httpRequestMethod variable using the const keyword. Therefore, we know that it's impossible to reassign the variable later; it'll always hold the value "GET". TypeScript understands that and automatically infers the string literal type "GET" to represent this piece of information in the type system.

Literal Type Widening

Let's now see what happens if we use the let keyword (instead of const) to declare the httpRequestMethod variable:

// Type: string
let httpRequestMethod = "GET";

TypeScript now performs what's known as literal type widening. The httpRequestMethod variable is inferred to have type string. We're initializing httpRequestMethod with the string "GET", but since the variable is declared using the let keyword, we can assign another value to it later:

// Type: string
let httpRequestMethod = "GET";

// OK, no type error
httpRequestMethod = "POST";

The later assignment of the value "POST" is type-correct since httpRequestMethod has type string. TypeScript inferred the type string because we most likely want to change the value of a variable declared using the let keyword later on. If we didn't want to reassign the variable, we should've used the const keyword instead.

Let's now look at our enum-style mapping object:

const HTTPRequestMethod = {
  CONNECT: "CONNECT",
  DELETE: "DELETE",
  GET: "GET",
  HEAD: "HEAD",
  OPTIONS: "OPTIONS",
  PATCH: "PATCH",
  POST: "POST",
  PUT: "PUT",
  TRACE: "TRACE"
};

What type does HTTPRequestMethod.GET have? Let's find out:

// Type: string
const httpRequestMethod = HTTPRequestMethod.GET;

TypeScript infers the type string for our httpRequestMethod variable. This is because we're initializing the variable with the value HTTPRequestMethod.GET (which has type string), so type string is inferred.

So why does HTTPRequestMethod.GET have type string and not type "GET"? We're initializing the GET property with the string literal "GET", and the HTTPRequestMethod object itself is defined using the const keyword. Shouldn't the resulting type be the string literal type "GET"?

The reason that TypeScript infers type string for HTTPRequestMethod.GET (and all the other properties) is that we could assign another value to any of the properties later on. To us, this object with its ALL_UPPERCASE property names looks like an enum which defines string constants that won't change over time. However, to TypeScript this is just a regular object with a few properties that happen to be initialized with string values.

The following example makes it a bit more obvious why TypeScript shouldn't infer a string literal type for object properties initialized with a string literal:

// Type: { name: string, jobTitle: string }
const person = {
  name: "Marius Schulz",
  jobTitle: "Software Engineer"
};

// OK, no type error
person.jobTitle = "Front End Engineer";

If the jobTitle property were inferred to have type "Software Engineer", it would be a type error if we tried to assign any string other than "Software Engineer" later on. Our assignment of "Front End Engineer" would not be type-correct. Object properties are mutable by default, so we wouldn't want TypeScript to infer a type which restricts us from performing perfectly valid mutation.

So how do we make the usage of our HTTPRequestMethod.GET property in the function call type-check correctly? We need to understand non-widening literal types first.

Non-Widening Literal Types

TypeScript has a special kind of literal type that's known as a non-widening literal type. As the name suggests, non-widening literal types will not be widened to a more generic type. For example, the non-widening string literal type "GET" will not be widened to string in cases where type widening would normally occur.

We can make the properties of our HTTPRequestMethod object receive a non-widening literal type by applying a type assertion of the corresponding string literal type to every property value:

const HTTPRequestMethod = {
  CONNECT: "CONNECT" as "CONNECT",
  DELETE: "DELETE" as "DELETE",
  GET: "GET" as "GET",
  HEAD: "HEAD" as "HEAD",
  OPTIONS: "OPTIONS" as "OPTIONS",
  PATCH: "PATCH" as "PATCH",
  POST: "POST" as "POST",
  PUT: "PUT" as "PUT",
  TRACE: "TRACE" as "TRACE"
};

Now, let's check the type of HTTPRequestMethod.GET again:

// Type: "GET"
const httpRequestMethod = HTTPRequestMethod.GET;

And indeed, now the httpRequestMethod variable has type "GET" rather than type string. The type of HTTPRequestMethod.GET (which is "GET") is assignable to the type of the method parameter (which is "GET" | "POST"), and therefore the fetchJSON function call will now type-check correctly:

// OK, no type error
fetchJSON("https://example.com/", HTTPRequestMethod.GET)
  .then(data => {
    // ...
  });

This is great news, but take a look at the number of type assertions we had to write to get to this point. That is a lot of noise! Every key/value pair now contains the name of the HTTP request method three times. Can we simplify this definition? Using TypeScript's const assertions feature, we most certainly can!

const Assertions for Literal Expressions

Our HTTPRequestMethod variable is initialized with a literal expression which is an object literal with several properties, all of which are initialized with string literals. As of TypeScript 3.4, we can apply a const assertion to a literal expression:

const HTTPRequestMethod = {
  CONNECT: "CONNECT",
  DELETE: "DELETE",
  GET: "GET",
  HEAD: "HEAD",
  OPTIONS: "OPTIONS",
  PATCH: "PATCH",
  POST: "POST",
  PUT: "PUT",
  TRACE: "TRACE"
} as const;

A const assertion is a special type assertion that uses the const keyword instead of a specific type name. Using a const assertion on a literal expression has the following effects:

  1. No literal types in the literal expression will be widened.
  2. Object literals will get readonly properties.
  3. Array literals will become readonly tuples.

With the const assertion in place, the above definition of HTTPRequestMethod is equivalent to the following:

const HTTPRequestMethod: {
  readonly CONNECT: "CONNECT";
  readonly DELETE: "DELETE";
  readonly GET: "GET";
  readonly HEAD: "HEAD";
  readonly OPTIONS: "OPTIONS";
  readonly PATCH: "PATCH";
  readonly POST: "POST";
  readonly PUT: "PUT";
  readonly TRACE: "TRACE";
} = {
  CONNECT: "CONNECT",
  DELETE: "DELETE",
  GET: "GET",
  HEAD: "HEAD",
  OPTIONS: "OPTIONS",
  PATCH: "PATCH",
  POST: "POST",
  PUT: "PUT",
  TRACE: "TRACE"
};

We wouldn't want to have to write this definition by hand. It's verbose and contains a lot of repetition; notice that every HTTP request method is spelled out four times. The const assertion as const, on the other hand, is very succinct and the only bit of TypeScript-specific syntax in the entire example.

Also, observe that every property is now typed as readonly. If we try to assign a value to a read-only property, TypeScript will product a type error:

// Error: Cannot assign to 'GET'
// because it is a read-only property.
HTTPRequestMethod.GET = "...";

With the const assertion, we've given our HTTPRequestMethod object enum-like characteristics. But what about proper TypeScript enums?

Using TypeScript Enums

Another possible solution would've been to use a TypeScript enum instead of a plain object literal. We could've defined HTTPRequestMethod using the enum keyword like this:

enum HTTPRequestMethod {
  CONNECT = "CONNECT",
  DELETE = "DELETE",
  GET = "GET",
  HEAD = "HEAD",
  OPTIONS = "OPTIONS",
  PATCH = "PATCH",
  POST = "POST",
  PUT = "PUT",
  TRACE = "TRACE"
}

TypeScript enums are meant to describe named constants, which is why their members are always read-only. Members of a string enum have a string literal type:

// Type: "GET"
const httpRequestMethod = HTTPRequestMethod.GET;

This means our function call will type-check when we pass HTTPRequestMethod.GET as an argument for the method parameter:

// OK, no type error
fetchJSON("https://example.com/", HTTPRequestMethod.GET)
  .then(data => {
    // ...
  });

However, some developers don't like to use TypeScript enums in their code because the enum syntax is not valid JavaScript on its own. The TypeScript compiler will emit the following JavaScript code for our HTTPRequestMethod enum defined above:

var HTTPRequestMethod;
(function (HTTPRequestMethod) {
    HTTPRequestMethod["CONNECT"] = "CONNECT";
    HTTPRequestMethod["DELETE"] = "DELETE";
    HTTPRequestMethod["GET"] = "GET";
    HTTPRequestMethod["HEAD"] = "HEAD";
    HTTPRequestMethod["OPTIONS"] = "OPTIONS";
    HTTPRequestMethod["PATCH"] = "PATCH";
    HTTPRequestMethod["POST"] = "POST";
    HTTPRequestMethod["PUT"] = "PUT";
    HTTPRequestMethod["TRACE"] = "TRACE";
})(HTTPRequestMethod || (HTTPRequestMethod = {}));

It's entirely up to you to decide whether you want to use plain object literals or proper TypeScript enums. If you want to stay as close to JavaScript as possible and only use TypeScript for type annotations, you can stick with plain object literals and const assertions. If you don't mind using non-standard syntax for defining enums and you like the brevity, TypeScript enums could be a good choice.

const Assertions for Other Types

You can apply a const assertion to …

  • string literals,
  • numeric literals,
  • boolean literals,
  • array literals, and
  • object literals.

For example, you could define an ORIGIN variable describing the origin in 2-dimensional space like this:

const ORIGIN = {
  x: 0,
  y: 0
} as const;

This is equivalent to (and much more succinct than) the following declaration:

const ORIGIN: {
  readonly x: 0;
  readonly y: 0;
} = {
  x: 0,
  y: 0
};

Alternatively, you could've modeled the representation of a point as a tuple of the X and Y coordinates:

// Type: readonly [0, 0]
const ORIGIN = [0, 0] as const;

Because of the const assertion, ORIGIN is typed as readonly [0, 0]. Without the assertion, ORIGIN would've been inferred to have type number[] instead:

// Type: number[]
const ORIGIN = [0, 0];

This post is part of the TypeScript Evolution series.