Narrowing in TypeScript - Part 2 - Gotchas that often trip up developers
Welcome to the second part of our deep dive into TypeScript narrowing! In Part 1, we explored the essential building blocks of narrowing, covering foundational tools like the typeof and in operators, manual type guards for complex objects, and how throwing errors can effectively prune type possibilities. We established that narrowing isn’t just about satisfying the compiler—it’s about proving the safety of our code through logical flow.
Now that we have the foundations down, it’s time to look at where things get tricky. In this post, we’ll explore the “gotchas” that often trip up developers, understand why narrowing sometimes breaks across different scopes, and master advanced patterns like discriminated unions and the never type to write more robust and scalable TypeScript.
Narrowing with Boolean won’t work
Consider this version of the validateUsername function, that isn’t working as expected when using JavaScript’s Boolean() function:
function validateUsername(username: string | null): boolean {
// Why isn't this working?
const isUsernameOK = Boolean(username);
if (isUsernameOK) {
return username.length > 5;
}
return false;
}
However, if we use a double bang !! to convert username into a boolean, everything works as expected:
// Works as Expected!
const isUsernameOK = !!username;
Why is this?
It ends up that TypeScript is really good at understanding operator syntax for “not” (!), “or” (||) and “and” (&&), but when looking at Boolean(username), TypeScript only sees the Boolean part. It doesn’t recognize that it’s related to the the username.
This is just one of the gotchas that comes up when narrowing. We’ll find more next.
Gotchas When Narrowing a Map in TypeScript
Let’s take a look at another gotcha with narrowing in TypeScript.
Here we have a processUserMap function that takes in an eventMap that is a Map containing a key of string and a value of Event. The Event has a message string on it:
type Event = {
message: string;
};
const processUserMap = (eventMap: Map<string, Event>) => {
if (eventMap.has("error")) {
const message = eventMap.get("error").message; // red squiggly line under `eventMap.get("error")`
throw new Error(message);
}
};
The implementation above has an error under the eventMap.get("error") line. This errors because it could possibly be undefined so we wouldn’t be able to access a property off of it.
The issue with our code is that TypeScript doesn’t understand the relationship between .has and .get on a Map like it does with a regular object.
In a Map, the .has() function just returns a boolean. TypeScript doesn’t know that the boolean is related to the Map in any way, so when it tries to access the value with .get(), it returns Event | undefined, instead of just Event.
Now let’s rewrite this function in a way that doesn’t change the behavior, but makes TypeScript’s narrowing work with us instead of against us, by extracting the event into a constant. Then we can check if the event exists and use scoping to our advantage:
const processUserMap = (eventMap: Map<string, Event>) => {
const event = eventMap.get("error");
if (event) {
const message = event.message;
throw new Error(message);
}
};
This refactored version of the code works a bit more closely to what TypeScript wants to do in figuring out the relationship between variables instead of using the Map’s built in methods like has or get.
Narrowing Return Types with TypeScript
Here we have a throwError function that takes in a message and, as the name suggests, throws an error. It also has been given a return type of undefined, indicating that the function returns undefined:
const throwError = (message: string): undefined => {
throw new Error(message);
};
The handleSearchParams function takes in params which might contain an id of string. Inside the handleSearchParams function, we’re trying to get id into a spot where it’s actually narrowed down to string.
If the id is defined and is a string, then id will end up being a string. But if it’s not defined, or if it is a falsy value, then we will call the throwError function and throw a new error message “No id provided”:
const handleSearchParams = (params: { id?: string }) => {
const id = params.id || throwError("No id provided");
type test = Expect<Equal<typeof id, string>>; // red squiggly line under Equal<>
return id;
};
We have the red squiggly line under Equal<> because the type of id is string | undefined instead of just string.
The reason why the throwError function doesn’t work as expected is because of the undefined return type annotation.
In reality, we are not actually returning undefined from this function.
Instead, we could specify the return type as never:
const throwError = (message: string): never => {
throw new Error(message);
};
By using never as a return type, we can leverage types to narrow down our possible results.
To illustrate how this works, create a new Example type set to string | never. When hovering over Example, we can see that TypeScript removes the never from the union to give us just string:
type Example = string | never;
// hovering over Example shows:
type Example = string;
This behavior means that in our handleSearchParams function, the params.id can either be a string or never, which results in the id being typed as a string.
Taking advantage of this behavior allows us to throw errors in situ, which is a nice pattern to use in TypeScript.
We could also remove the return type annotation and get the same behavior because TypeScript will infer the return type never. This could be an argument for not using explicit return types in your code, since it’s one less annotation to worry about and the return type would be automatically updated across the codebase if you were to make changes to the function.
Narrowing in Different Scopes
Here we have a function findUsersByName that takes two arguments: a searchParams object which could contain a name string, and a users array of objects, each having id and name properties.
Inside the function, we check if searchParams.name exists. If it does, we return a filtered array of users based on whether their name includes the provided searchParams.name. If searchParams.name doesn’t exist, we return the full users array:
const findUsersByName = (
searchParams: { name?: string },
users: {
id: string;
name: string;
}[],
) => {
if (searchParams.name) {
return users.filter((user) => user.name.includes(searchParams.name)); // red squiggly line under searchParams.name
}
return users;
};
Despite leveraging TypeScript’s ability to narrow down the type by ensuring searchParams.name exists, we have an error inside the filter method when we attempt to use user.name.includes(searchParams.name):
// hovering over searchParams.name shows:
Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
Type 'undefined' is not assignable to type 'string'.
The error tells us that the argument could be either a string or undefined, which conflicts with the expected string data type.
Let’s determine why this error is occurring and refactor the function in a way that eliminates the error while retaining the original functionality. We’re not going to change the type definitions, only the function body.
Remember, scopes are a JavaScript concept, not a TypeScript concept
Let’s start by looking at all of the different scopes there are in the code example:
Module Scope vs. Global Scope
If we were to declare const example = 123 above the findUsersByName function, example would be in the module scope. Because we have imports and exports, this code is in a module. If this code were in a script instead, example would be in the global scope.
Function Scope
Next, let’s consider function scopes. Take the findUserByName function as an example. It’s declared in the module scope, and its function body defines its own scope. Variables declared inside the function can’t be accessed outside the function because they’re in different scopes.
const findUsersByName = (
// ...
) => {
const example = 123; // This variable is only accessible inside the function
if (searchParamsName) {
return users.filter((user) => {
return user.name.includes(searchParamsName);
});
}
return users;
};
Block Scope
In addition to module and function scopes, we also have block scopes, such as those created by if statements like if (searchParamsName) inside of the function.
This is where things get interesting with TypeScript’s type narrowing.
TypeScript’s Narrowing Behavior
In previous examples, we’ve seen that when we check if searchParams.name exists inside an if statement, TypeScript considers it safe to call toUpperCase() on it:
// inside findUsersByName function
if (searchParams.name) {
searchParams.name.toUpperCase();
}
The above works because the type of searchParams.name has been narrowed down to exclude undefined.
However, if we move that same code to the outer scope where the check hasn’t been performed, TypeScript will complain because searchParams.name could potentially be undefined:
const findUsersByName = (
// ...
) => {
searchParams.name.toUpperCase(); // red squiggly line under searchParams.name
if (searchParams.name) {
...
}
// ...
Function Scope and Narrowing
Interestingly, if we move the code that relies on the narrowed type into a function passed to .filter, the narrowing breaks again. Why is that?
// inside findUsersByName function
if (searchParams.name) {
return users.filter((user) => {
return user.name.includes(searchParams.name); // red squiggly line under searchParams.name
});
}
As JavaScript developers, we know that .filter is called synchronously. However, TypeScript doesn’t have that knowledge. It can’t be sure that searchParams.name won’t be mutated or changed between the if statement and the .filter call.
Working Around Narrowing Issues
To solve this problem, we can save searchParams.name to a variable.
By using name inside the .filter function, TypeScript’s narrowing works as expected. Since name is declared as a const, TypeScript knows it won’t be reassigned and can safely narrow its type.
const findUsersByName = (
// ...
) => {
const name = searchParams.name;
// let name = searchParams.name; // Also works with let!
if (name) {
return users.filter((user) => {
return user.name.includes(name); // no error
});
}
return users;
};
As of TypeScript 5.5, using a let instead of a const also works because TypeScript now tracks whether a let variable has been reassigned.
Understanding the Difference
You might wonder why TypeScript treats object properties differently than variables when it comes to narrowing across scopes.
The reason is that object properties can have additional complexities, such as getters, which variables can’t. As a result, TypeScript tends to be more cautious when narrowing types based on object properties compared to variables.
Narrowing a Discriminated Union with a Switch
Let’s analyze the calcuateArea function that uses an if-else statement to calculate the area of a circle or square without doing any destructuring:
function calculateArea(shape: Shape) {
if (shape.kind === "circle") {
return Math.PI * shape.radius * shape.radius;
} else {
return shape.sideLength * shape.sideLength;
}
}
We need to refactor this function to use a switch statement based on the kind property instead of an if-else statement.
The reason behind this refactor is that switch statements make it easier to extend the function to allow for more shapes without repeating if (shape.kind === "whatever") over and over again.
As we refactor, we need to ensure that all the behavior is preserved. The area to be calculated accurately, regardless of the shape. Take note of how TypeScript’s type narrowing will seamlessly work within your new switch statement construct to ensure types are correctly understood and handled.
The first step to refactor is to clear out the calcuateArea function and add the switch keyword and specify shape.kind as our switch condition. TypeScript’s intelligence kicks in and suggests autocompletion for circle and square, based on the discriminated union.
Each case will be handled as they were with the if-else statement:
function calculateArea(shape: Shape) {
switch (shape.kind) {
case "circle": {
return Math.PI * shape.radius * shape.radius;
}
case "square": {
return shape.sideLength * shape.sideLength;
}
// Potential additional cases for more shapes
}
}
Not Accounting for All Cases
As an experiment, comment out the case where the kind is square:
function calculateArea(shape: Shape) {
switch (shape.kind) {
case "circle": {
return Math.PI * shape.radius * shape.radius;
}
// case "square": {
// return shape.sideLength * shape.sideLength;
// }
// Potential additional cases for more shapes
}
}
Now when we hover over the function, we see that the return type is number | undefined. This is because TypeScript is smart enough to know that if we don’t return a value for the square case, the output will be undefined for any square shape.
// hovering over `calculateArea` shows
function calculateArea(shape: Shape): number | undefined
Wrapping Up
Discriminated unions and switch statements work seamlessly together in TypeScript!
Note that curly braces after cases are optional, but they allow for multi-line code blocks if the logic for calculating the area is more complex.
Key Takeaways: Beyond Basic Type Guards
Narrowing is the engine that makes TypeScript feel truly “intelligent.” It’s the bridge between the flexibility of JavaScript’s dynamic nature and the safety of a strongly typed system. By narrowing, you aren’t just making the “red squiggly lines” go away - you’re documenting the logical flow of your application for both the compiler and your future self.
In this 2 parts guide, we’ve covered the essential tools for your narrowing toolkit:
- Foundational Guards: Using
typeofand theinoperator to safely distinguish between types. - Truthiness & Logic: Understanding how
!!and logical operators help TypeScript track type state, and why helper functions likeBoolean()can sometimes obscure that relationship. - Control Flow Narrowing: Leveraging
throwstatements and thenevertype to prune impossible code paths and simplify your logic. - The Scope Gotcha: Recognizing how closures and object properties can break narrowing, and how to fix it by capturing values in local constants.
- Discriminated Unions: Using
switchstatements to create scalable, exhaustive logic that evolves alongside your data structures.
Mastering these patterns allows you to move away from “telling” TypeScript what a type is (via assertions) and toward “proving” what it is through code. This leads to fewer runtime errors and a much more enjoyable developer experience.
Happy narrowing!