There are many important concepts to understand to be able to write TypeScript effectively. This article will discuss two of them: Type widening and narrowing. Widening and Narrowing types is about expanding and reducing the possibilities which a type could represent.
Type Widening
To represent an absence of something, JavaScript has two values: null
and undefined
. These are special because in TypeScript, the only thing of type null
is the value null
, and the only thing of type undefined
is the value undefined
. let
variables initialized to null
and undefined
are widened to any
if strict null checking is off. To make sure the types remain the same, in your tsconfig.json
file, set --strictNullChecks
to true
.
// --strictNullChecks: false
let a = null; // type any
const b = null; // type null
// --strictNullChecks: true
let a = null; // type null
let b = undefined; // type undefined
Note that the compiler widens on assignment. null
is still of type null
, and undefined
is still of type undefined
.
Literal widening
Literal widening in TypeScript is when a literal type gets treated as its base type. When you declare a variable using the const
keyword and initialize it with a literal value, TypeScript will infer a literal type for that variable. TypeScript knows that once a primitive is assigned with const
its value will never change, so it infers the most narrow type it can for that variable.
const num = 2; // type 2;
const name = "Tolu"; // type 'Tolu'
const isTrue = true; // type true
However, when you declare a variable with let
or var
, you're telling Typescript that its value can be changed later. So its type is inferred to be the base type that its literal value belongs to.
let surname = "Agboola"; // type string
let num2 = 10; // type number
let bool = true; // type boolean
In the above example, surname
is initialized with the let
keyword, so TypeScript gave it a wider type of string
therefore giving it a wide set of possibilities. If Typescript were to infer literal types for let
variables, trying to assign a different value from the inferred literal would cause an error at compile time.
Now, when a narrow type is reassigned to a mutable location e.g a let
variable, the new variable will be widened to and treated as its respective widened type. For example, if you assign the value of the constant name
to a mutable variable widenedName
, the type of widenedName
will be the base type of name
which is type string
:
let widenedName = name; // type string
let widenedNum = num; // type number
let narrowedName: "Tolu" = name; //narrowed to type 'Tolu';
Enums are a way to define a set of named constants and enumerate the possible values for a type. They are unordered data structures that map keys to values, similar to objects. The values of enum members are auto incremented unless specific values are assigned to them. Take the following enum:
enum Stuff {
X, // 0
Y, // 1
Z, // 2
}
let e = Stuff.X; // type Stuff
const f = Stuff.Y; // type Stuff.Y
The mutable variable e
to which enum member X
is assigned to is widened to type Stuff
, the containing enum of X
. However, when const
is used, TypeScript narrows the type of f
to the member of the enum itself, Stuff.Y
.
What literal types widen?
According to this handbook, literal types widen to their respective supertype:
- Number literal types like 1 widen to
number
. - String literal types like 'hi' widen to
string
. - Boolean literal types like true widen to
boolean
. - Enum members widen to their containing enum.
Non-widening literal types
You can prevent your type from being widened with explicit annotation:
const nonWideVar: 43 = 43; // type 43
let newVar = nonWideVar; // type 43
This way, even if you reassign the value to a mutable location, the type will remain narrow.
Type Narrowing
TypeScript provides different ways to combine types. One of them is Union type. A union type is formed from two or more other types, and represents a value that can be any one of them. To understand narrowing, you need to know what declared type and computed type mean. The declared type of a variable is the one it is declared with, while the computed type varies based on context. Consider the following code:
let strNum: string | number; // type string | number (declared type)
strNum = "var"; // OK
strNum = 10; // OK
strNum = false; // Error: Type 'boolean' is not assignable to type 'string | number'.
You can assign either a string or a number to strNum
without any complaints from TypeScript because the possibilities of its values have been specified in the union type. However, if you assign any other type of value to it, the compiler will give an error.
Function parameters can also be of union type:
function logType(val: string | boolean) {
if (typeof val === "string") {
console.log("Value is a string"); // Computed type here is string
} else if (typeof val === "boolean") {
console.log("Value is boolean"); // Computed type here is boolean
}
}
logType("Value"); // OK
logType(true); // OK
logType(10); // Error: Argument of type '10' is not assignable to parameter of type 'string | boolean'.
Now, let's take a look at the logType
function above. It receives one argument, val
, that can either be a string or boolean. The first block of code within the function checks the type of the argument and will only run if the computed type of the argument is a string. It basically narrows the type of val
to string
allowing it to be temporarily treated as a string within that context. The same thing is happening within the second block which narrows the argument to boolean. Outside of those contexts, the type remains string | boolean
. Narrowing, which is the removal of types from a union, has just taken place.
Conclusion
In this article, we've seen how and why TypeScript widens the types of mutable variables, how strict null checking affects widening of null
and undefined
values. We saw what types can be widened, and how to prevent widening with explicit annotation. We also discussed what declared types and computed types mean, and how they play a part in type narrowing.
I hope you have gained some value from this explanation. Let me know your thoughts in the comments.
Thanks for reading!
Top comments (2)
Something doesn't make sense:
"Note that the compiler widens on assignment. null is still of type null, and undefined is still of type undefined." -> This should be "Note that the compiler [doesn't widen] narrows on assignment...".
Nvm, since it's in the context of initialization to type inference, it can be regarded as widening, but the labeling is trivial in this case. Good article btw!