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 mutations.
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:
- No literal types in the literal expression will be widened.
- Object literals will get
readonly
properties. - 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 article and 44 others are part of the TypeScript Evolution series. Have a look!