Narrowing in TypeScript - Part 1 - The Basics

Narrowing is one of TypeScript’s most powerful features. Narrowing types is a very useful technique in TypeScript, and using typeof is just one way to do it.

In this post, I’ll cover some essential building blocks of narrowing, presenting foundational tools like the typeof and in operators, manual type guards for complex objects, and how throwing errors can effectively prune type possibilities.

Narrowing Unions with typeof

In the example below, we have a convertTime function that takes in a parameter of time, that is either a string or a number.

Notice the if statement that uses the typeof operator to see if time is a string:

const convertTime = (time: string | number) => {
  if (typeof time === "string") {
    console.log(time); // `time` is a string
  } else {
    console.log(time); // `time` is  a number
  }

  console.log(time); // `time` is string | number
};

Scopes are very important when it comes to narrowing things down.

Inside of the first if block, TypeScript knows that time is a string. This is because we’ve used the typeof operator to narrow down the type of time to string.

In the else conditional, time is going to be number since that’s what’s is left inside of the string | number union.

However, outside of the if statement the type of time is still string | number because there is no longer a narrowing scope.

For the sake of illustration, if we add a boolean to the time union, the first if block will still narrow down the type of time to string, but the else block will now have a type of number | boolean:

const convertTime = (time: string | number | boolean) => {
  if (typeof time === "string") {
    console.log(time); // `time` is a string
  } else {
    console.log(time); // `time` is  number | boolean
  }

  console.log(time); // `time` is string | number | boolean
}

Conditional Narrowing in TypeScript

Let’s say we have a function called validateUsername that takes in either a string or null, and will always return a boolean:

function validateUsername(username: string | null): boolean {
  return username.length > 5; // red squiggly line under `username`

  return false;
}

However, we have an error underneath username inside of the function body, because it could possibly be null and we are trying to access a property off of it.

We could rewrite in several different ways the validateUsername function to add narrowing so that the null case is handled and we don’t have any more errors. Let’s check them:

Option 1: Check for username

We could use an if statement to check if username exists. If it does, we can return username.length > 5, otherwise we can return false:

function validateUsername(username: string | null): boolean {
	// Rewrite this function to make the error go away
	if (username) {
		return username.length > 5;
	}

	return false;
}

This implementation corresponds to the logic we wanted in the first place, but it doesn’t account for other behavior we would want in the real world like checking for empty strings.

Option 2: Check if typeof username is "string"

We could use typeof to check if the username is a string:

function validateUsername(username: string | null): boolean {
	if (typeof username === "string") {
		return username.length > 5;
	}

	return false;
}

Option 3: Check if typeof username is not "string"

Similar to the above, we could check if typeof username !== "string".

In this case, if username is not a string, we know it’s null and could return false right away. Otherwise, we’d return the check for length being greater than 5:

function validateUsername(username: string | null | undefined): boolean {
	if (typeof name !== "string") {
		return false;
	}
	return username.length > 5;
}

This approach has the unconventional implementation of the initial problem, but it’s nice to know how to properly check for a null, so you should be aware of this method as well.

Option 4: Check if typeof username is "object"

An unconventional approach to checking for null is by exploiting a JavaScript quirk where the type of null is equal to "object".

The body is otherwise the same as the previous option:

function validateUsername(username: string | null): boolean {
	if (typeof username === "object") {
		return false;
	}

	return username.length > 5;
}

Option 5: Extract the check into its own variable

Finally, for readability and reusability purposes you could store the check in its own variable isUsernameNotNull and negate the boolean. Since we don’t care about the return value or the naming in the guard clause, we can also negate the value and use the double-bang to ensure we have just a boolean.

Here’s what this would look like:

function validateUsername(username: string | null): boolean {
	const isUsernameOK = typeof username === "string";

	if (isUsernameOK) {
		return username.length > 5;
	}

	return false;
}

All of the above options use if statements to perform checks by narrowing types by using typeof.

No matter which option you go with, remember that you can always use an if statement to narrow your type and add code to the case that the condition passes.

Narrowing with in Statements

Here we have a HandleResponse function that takes in a type of APIResponse, which is a union of two types of objects.

The goal of the HandleResponse function is to check whether the provided object has a data property. If it does, the function should return the id property. If not, it should throw an Error with the message from the error property.

type APIResponse =
    | {
          data: {
              id: string
          }
      }
    | {
          error: string
      }

const handleResponse = (response: APIResponse) => {
  // How do we check if 'data' is in the resopnse?
  if (true) {
    return response.data.id;
  } else {
    throw new Error(response.error);
  }
}

Currently, there are several errors being thrown:

  • The first error is Property 'data' does not exist on type 'APIResponse'
  • Then we have the inverse error, where Property 'error' does not exist on type 'APIResponse':
    • Property data does not exist on type 'APIResponse'.ts.
  • We’re also getting a warning because we have unreachable code on the if (true) statement.

Dynamically Handling Different API Responses in TypeScript

Let’s find the correct syntax for narrowing down the types within the HandleResponse function’s if condition, inside of the function without modifying any other parts of the code.

It may be tempting to change the APIReponse type to make it a little bit different. For example, we could add an error as a string on one side and data as undefined on the other branch:

// Don't change the type like this!
type APIResponse = 
  | {
    data: {
      id: string;
    };
    error: undefined;
  }
| { 
    data?: undefined;
    error: string;
  };

However, there’s a much simpler way to do this.

We can use an in operator to check if a specific key exists on json.

In this example, it would check for the key data:

const handleResponse = (response: APIResponse) => {
  if ("data" in response) {
    return response.data.id
  } else {
    throw new Error(response.error)
  }
}

The neat thing about this approach, is that it manages to narrow the type of the response without needing an environment where narrowing can occur.

If the response isn’t the one with data on it, then it must be the one with error, so we can throw an Error with the error message.

Using in here allows gives us a great way to narrow down objects that might have different keys from one another.

Narrowing Unknown in a Large Conditional Statement

Here we have a parseValue function that takes in an unknown value:

const parseValue = (value: unknown) => {
  if (true) {
    return value.data.id; // red squiggly line under `value`
  }

  throw new Error("Parsing error!");
};

The goal of this function is to return the id property of the data property of the value object. If the value object doesn’t have a data property, then it should throw an error.

Let’s modify the parseValue function so that the errors go away. It’s going to require a large conditional statement!

Here’s our starting point:

const parseValue = (value: unknown) => {
  if (true) {
    return value.data.id; // red squiggly line under `value`
  }

  throw new Error("Parsing error!");
};

To fix the error, we’ll need to narrow the type using conditional checks. Let’s take it step-by-step.

  • First, we’ll check if the type of value is an object by replacing the true with a type check;
  • Then we’ll check if the value argument has a data attribute using the in operator;
  • With this, TypeScript is complaining that value is possibly null. To fix this, we can add && value to our first condition to make sure it isn’t null;
  • Now things are a bit better, but we’re still getting an error on value.data being typed as unknown;
    • What we need to do now is to narrow the type of value.data to an object and make sure that it isn’t null. At this point we’ll also add specify a return type of string to avoid returning an unknown type;
  • Finally, we’ll add a check to ensure that the id is a string. If not, TypeScript will throw an error.

The final code will look like this:

const parseValue = (value: unknown): string => {
  if (
    typeof value === "object" &&
    value !== null &&
    "data" in value &&
    typeof value.data === "object" &&
    value.data !== null &&
    "id" in value.data &&
    typeof value.data.id === "string"
  ) {
    return value.data.id;
  }

  throw new Error("Parsing error!");
};

Now when we hover over parseValue, we can see that it takes in an unknown input and always returns a string:

// hovering over `parseValue` shows:
const parseValue: (value: unknown) => string

Thanks to this huge conditional, our error messages are gone!

The Zod library would allow us to do this in a single line of code, but knowing how to do this manually is a great exercise to understand how narrowing works in TypeScript.

Narrowing by Throwing Errors

Here we have a line of code that uses document.getElementById to fetch an HTML element, which can return either an HTMLElement or null:

const appElement = document.getElementById("app");

We want to change the code so that the appElement is always defined at runtime without lying to TypeScript, by crashing the app.

In order to crash the app if appElement does not exist, we can add an if statement that checks if appElement is null or does not exist, then throws an error:

if (!appElement) {
  throw new Error("Could not find app element");
}

By adding this error condition, we can be sure that we will never reach any subsequent code if appElement is null.

If we hover over appElement after the if statement, we can see that TypeScript now knows that appElement is an HTMLElement.

Throwing errors like this can help you identify issues at runtime. In this specific case, it acts like a type annotation that narrows down the code inside of the immediate if statement scope.

In general, this technique is useful any time you need to manage logical flow in your applications.

Part 1 Wrap-Up: Building Your Foundation

We have covered the fundamental building blocks of type narrowing in TypeScript. By using typeof, the in operator, and simple truthiness checks, we’ve seen how to guide the compiler to understand our code’s logic without relying on unsafe type assertions. We also explored how throwing errors can serve as a powerful narrowing tool by effectively pruning impossible code paths.

But while these tools are powerful, they aren’t always enough. In Part 2, we will dive deeper into the more nuanced side of narrowing, including:

  • Common “Gotchas”: Why narrowing with Boolean() or Map.has() might not work as you expect.
  • The Scope Problem: Understanding how closures and object properties can sometimes “forget” their narrowed types.
  • Advanced Patterns: Leveraging the never type and mastering discriminated unions with switch statements for cleaner, more scalable code.

Stay tuned for the next post where we’ll tackle these advanced patterns!

Happy narrowing!