Marius Schulz
Marius Schulz
Front End Engineer

Literal Type Widening in TypeScript

In my previous post about better type inference in TypeScript 2.1, I explained how TypeScript infers literal types for const variables and readonly properties with literal initializers. This post continues this discussion and draws a difference between widening and non-widening literal types.

#Widening Literal Types

When you declare a local variable using the const keyword and initialize it with a literal value, TypeScript will infer a literal type for that variable:

const stringLiteral = "https"; // Type "https"
const numericLiteral = 42; // Type 42
const booleanLiteral = true; // Type true

Because of the const keyword, the value of each variable cannot be changed later, so a literal type makes perfect sense. It preserves information about the exact value that was assigned.

If you take the constants defined above and assign them to let variables, each of the literal types will be widened to the respective widened type:

let widenedStringLiteral = stringLiteral; // Type string
let widenedNumericLiteral = numericLiteral; // Type number
let widenedBooleanLiteral = booleanLiteral; // Type boolean

In contrast to variables declared using the const keyword, variables declared using the let keyword can be changed later on. They are usually initialized with a certain value and mutated afterwards. If TypeScript were to infer a literal type for such let variables, trying to assign any other value than the specified literal would produce an error at compile-time.

For this reason, widened types are inferred for each of the above let variables. The same goes for enum literals:

enum FlexDirection {
  Row,
  Column,
}

const enumLiteral = FlexDirection.Row; // Type FlexDirection.Row
let widenedEnumLiteral = enumLiteral; // Type FlexDirection

To summarize, here are the rules for widening literal types:

So far we've been looking at widening literal types which are automatically widened when necessary. Let's now look at non-widening literal types which, as their name suggests, are not widened automatically.

#Non-Widening Literal Types

You can create a variable of a non-widening literal type by explicitly annotating the variable to be of a literal type:

const stringLiteral: "https" = "https"; // Type "https" (non-widening)
const numericLiteral: 42 = 42; // Type 42 (non-widening)

Assigning the value of a variable that has a non-widening literal type to another variable will not widen the literal type:

let widenedStringLiteral = stringLiteral; // Type "https" (non-widening)
let widenedNumericLiteral = numericLiteral; // Type 42 (non-widening)

Notice how the types are still "https" and 42. Unlike before, they haven't been widened to string and number, respectively.

#Usefulness of Non-Widening Literal Types

To understand why non-widening literals can be useful, let's look at widening literal types once again. In the following example, an array is created from two variables of a widening string literal type:

const http = "http"; // Type "http" (widening)
const https = "https"; // Type "https" (widening)

const protocols = [http, https]; // Type string[]

const first = protocols[0]; // Type string
const second = protocols[1]; // Type string

TypeScript infers the type string[] for the array. Therefore, array elements like first and second are typed as string. The notion of the literal types "http" and "https" got lost in the widening process.

If you were to explicitly type the two constants as "http" and "https", the protocols array would be inferred to be of type ("http" | "https")[] which represents an array that only contains the strings "http" or "https":

const http: "http" = "http"; // Type "http" (non-widening)
const https: "https" = "https"; // Type "https" (non-widening)

const protocols = [http, https]; // Type ("http" | "https")[]

const first = protocols[0]; // Type "http" | "https"
const second = protocols[1]; // Type "http" | "https"

Both first and second are typed as "http" | "https" now. This is because the array type doesn't encode the fact that the value "http" is at index 0 while "https" is at index 1. It just states that the array only contains values of the two literal types, no matter at which position. It also doesn't say anything about the length of the array.

If, for some reason, you wanted to retain the position information of the string literal types in the array, you could explicitly type the array as a two-element tuple:

const http = "http"; // Type "http" (widening)
const https = "https"; // Type "https" (widening)

const protocols: ["http", "https"] = [http, https]; // Type ["http", "https"]

const first = protocols[0]; // Type "http" (non-widening)
const second = protocols[1]; // Type "https" (non-widening)

Now, first and second are inferred to be of their respective non-widening string literal type.

#Further Reading

If you'd like to read more about the rationale behind widening and non-widening types, check out these discussions and pull requests on GitHub:

This article and 44 others are part of the TypeScript Evolution series. Have a look!