Recently, I was working on a TypeScript project where I had to fetch user data from an object based on dynamic string IDs. In that process, I got a TypeScript compile error stating that we couldn’t index the object with a string.
After searching for the solutions, I came to know that the issue was related to how TypeScript infers types when working with object keys. To fix it, I used type narrowing along with a proper index signature, and with this, I got the desired output.
In this blog post, I’ll explain how to use TypeScript Type Narrowing and safe type patterns in TypeScript.
What is Type Narrowing in TypeScript?
Type narrowing is the process of refining a broader type to a more specific one within a certain code block. This helps TypeScript understand your code better and provides improved type checking and IDE support.
Think of type narrowing as telling TypeScript, “I know this variable is definitely a string right now, even though it could also be a number in other places.”
For example, if you have a function that accepts either a string or a number, type narrowing lets you handle each case appropriately without TypeScript complaining about potential type mismatches.
Method 1: Using Type Guards with typeof
The simplest way to narrow types in TypeScript is by using the typeof operator. This works great for JavaScript’s primitive types.
function processValue(value: string | number) {
if (typeof value === "string") {
// TypeScript knows value is a string here
console.log(value.toUpperCase());
} else {
// TypeScript knows value is a number here
console.log(value.toFixed(2));
}
}
// Example usage
processValue("New York"); // Outputs: NEW YORK
processValue(42.123); // Outputs: 42.12Output:

In this example, TypeScript understands that inside the first block, value must be a string (because we checked), so it allows string methods like toUpperCase(). Similarly, in the second block, it knows the value must be a number.
Check out: Iterate Over Objects in TypeScript
Method 2: Narrowing with instanceof
When working with classes, the instanceof operator helps narrow down object types.
class Customer {
name: string;
loyaltyPoints: number;
constructor(name: string, points: number) {
this.name = name;
this.loyaltyPoints = points;
}
applyDiscount() {
return this.loyaltyPoints > 100 ? "15% discount" : "5% discount";
}
}
class Employee {
name: string;
department: string;
constructor(name: string, department: string) {
this.name = name;
this.department = department;
}
getAccessLevel() {
return this.department === "Management" ? "High" : "Standard";
}
}
function processUser(user: Customer | Employee) {
console.log(`Processing user: ${user.name}`);
if (user instanceof Customer) {
// TypeScript knows user is a Customer here
console.log(`Eligible for: ${user.applyDiscount()}`);
} else {
// TypeScript knows user is an Employee here
console.log(`Access level: ${user.getAccessLevel()}`);
}
}
// Example usage
processUser(new Customer("John Smith", 150));
processUser(new Employee("Sarah Jones", "Marketing"));Output:

The instanceof check helps TypeScript understand which specific class type we’re dealing with, enabling access to the appropriate methods.
Check out: Deep Clone an Object in TypeScript
Method 3: Discriminated Unions
Discriminated unions are a powerful pattern where you include a common property (the “discriminant”) that has a different literal type for each variant.
type CashPayment = {
method: "cash";
amount: number;
};
type CreditCardPayment = {
method: "credit";
cardNumber: string;
amount: number;
};
type BankTransferPayment = {
method: "transfer";
accountNumber: string;
amount: number;
};
type Payment = CashPayment | CreditCardPayment | BankTransferPayment;
function processPayment(payment: Payment) {
// Common property access is fine
console.log(`Processing $${payment.amount} payment`);
// Type narrowing based on the discriminant
switch (payment.method) {
case "cash":
// TypeScript knows payment is CashPayment here
console.log("No additional processing needed");
break;
case "credit":
// TypeScript knows payment is CreditCardPayment here
console.log(`Processing card: ${payment.cardNumber.slice(-4)}`);
break;
case "transfer":
// TypeScript knows payment is BankTransferPayment here
console.log(`Processing bank account: ${payment.accountNumber}`);
break;
}
}
// Example usage
processPayment({ method: "cash", amount: 49.99 });
processPayment({ method: "credit", cardNumber: "4111111111111111", amount: 129.99 });Output:

The method property acts as our discriminant, allowing TypeScript to narrow down the exact type based on its value.
Check out: Optional Parameters in TypeScript Interfaces
Method 4: User-Defined Type Guards
Sometimes the built-in type guards aren’t enough. TypeScript allows you to create custom type guards using predicate functions.
interface Product {
id: string;
name: string;
price: number;
}
interface DigitalProduct extends Product {
downloadUrl: string;
fileSize: number;
}
interface PhysicalProduct extends Product {
weight: number;
dimensions: {
length: number;
width: number;
height: number;
};
}
// Custom type guard
function isDigitalProduct(product: Product): product is DigitalProduct {
return (product as DigitalProduct).downloadUrl !== undefined;
}
function processProduct(product: Product) {
console.log(`Processing ${product.name} (ID: ${product.id})`);
if (isDigitalProduct(product)) {
// TypeScript knows product is DigitalProduct here
console.log(`Prepare download link: ${product.downloadUrl}`);
console.log(`File size: ${product.fileSize} MB`);
} else {
// TypeScript infers this must be a PhysicalProduct
const physicalProduct = product as PhysicalProduct;
console.log(`Prepare shipping for item weighing ${physicalProduct.weight} lbs`);
const { length, width, height } = physicalProduct.dimensions;
console.log(`Box dimensions: ${length}" × ${width}" × ${height}"`);
}
}
// Example usage
processProduct({
id: "DP001",
name: "Photoshop Course",
price: 79.99,
downloadUrl: "https://example.com/downloads/ps-course",
fileSize: 2500
});
processProduct({
id: "PP001",
name: "Ergonomic Keyboard",
price: 129.99,
weight: 2.5,
dimensions: {
length: 18,
width: 6,
height: 1.5
}
});The isDigitalProduct function returns a type predicate (product is DigitalProduct), which tells TypeScript that if the function returns true, the argument is definitely a DigitalProduct.
Check out: Extend Interfaces with Classes in TypeScript
Method 5: Type Assertions with the “as” Keyword
While not technically type-narrowing (since you’re bypassing TypeScript’s checks), sometimes you need to use type assertions when you know more about a type than TypeScript can infer.
function getStateData(stateCode: string): { name: string; population: number; capital: string } | null {
const stateData: { [key: string]: { name: string; population: number; capital: string } } = {
"NY": { name: "New York", population: 19.45, capital: "Albany" },
"CA": { name: "California", population: 39.51, capital: "Sacramento" },
"TX": { name: "Texas", population: 29.0, capital: "Austin" }
};
return stateCode in stateData ? stateData[stateCode] : null;
}
function displayStateInfo(stateCode: string) {
const data = getStateData(stateCode);
if (data) {
console.log(`State: ${data.name}`);
console.log(`Population: ${data.population} million`);
console.log(`Capital: ${data.capital}`);
} else {
console.log(`No data available for state code: ${stateCode}`);
}
}
displayStateInfo("NY");
displayStateInfo("ZZ");Output:

Here, we use as to tell TypeScript about the structure we know the data will have, allowing for proper type checking of property access.
The “!” Non-null Assertion Operator
The non-null assertion operator (!) is a way to tell TypeScript that you know a value won’t be null or undefined, even when TypeScript thinks it might be.
function getUserById(id: string): { name: string; email: string } | null {
const users: Record<string, { name: string; email: string }> = {
"1": { name: "John Doe", email: "john@example.com" },
"2": { name: "Jane Smith", email: "jane@example.com" }
};
return id in users ? users[id] : null;
}
function sendWelcomeEmail(userId: string) {
const user = getUserById(userId);
const email = user!.email; // not safe, use only if you're 100% sure user exists
console.log(`Sending welcome email to: ${email}`);
}
function sendWelcomeEmailSafe(userId: string) {
const user = getUserById(userId);
if (user) {
console.log(`Sending welcome email to: ${user.email}`);
} else {
console.log(`No user found with ID: ${userId}`);
}
}
sendWelcomeEmailSafe("1");
sendWelcomeEmailSafe("3");Output:

The first approach with ‘!’ is risky and should only be used when you’re absolutely certain the value won’t be null. The second approach, with proper narrowing, is safer and preferred.
Type narrowing is one of TypeScript’s most powerful features. It allows you to write code that’s both flexible and type-safe. By mastering these techniques, you can create more robust applications with fewer runtime errors.
Check out: TypeScript Constructor Overloading
Conclusion
In this tutorial, we learned how TypeScript’s type narrowing helps us write cleaner code by narrowing down broader types to more specific ones. We learned the different ways to narrow types, such as using typeof and instanceof checks, discriminated unions, and custom type guard functions.
We also learned about the use of type assertions with the’ as’ keyword in cases where we know more about a value than TypeScript does. By using these techniques, we can avoid common TypeScript errors and make our applications more reliable and easier to maintain.

I am Bijay Kumar, a Microsoft MVP in SharePoint. Apart from SharePoint, I started working on Python, Machine learning, and artificial intelligence for the last 5 years. During this time I got expertise in various Python libraries also like Tkinter, Pandas, NumPy, Turtle, Django, Matplotlib, Tensorflow, Scipy, Scikit-Learn, etc… for various clients in the United States, Canada, the United Kingdom, Australia, New Zealand, etc. Check out my profile.