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:
- String literal types are widened to type
string
- Numeric literal types are widened to type
number
- Boolean literal types are widened to type
boolean
- Enum literal types are widened to the type of the containing enum
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!