Hack Frontend Community

Type Narrowing in TypeScript

What is Type Narrowing?

Type Narrowing is the process by which TypeScript refines a variable's type from a more general to a more specific one based on checks in the code.

function process(value: string | number) {
  // Here value: string | number
  
  if (typeof value === 'string') {
    // Here value: string (type narrowed!)
    console.log(value.toUpperCase());
  } else {
    // Here value: number (only number remains)
    console.log(value.toFixed(2));
  }
}

Ways to Narrow Types

typeof Guard

Checking primitive types via typeof.

function printValue(value: string | number | boolean) {
  if (typeof value === 'string') {
    console.log(value.toUpperCase());
  } else if (typeof value === 'number') {
    console.log(value.toFixed(2));
  } else {
    console.log(value ? 'true' : 'false');
  }
}

Important:

typeof null returns 'object', this is a JavaScript feature!

function process(value: string | null) {
  if (typeof value === 'object') {
    // Here value is still string | null (null is object!)
    console.log(value);  // can be null
  }
}

instanceof Guard

Checking class membership.

class Dog {
  bark() {
    console.log('Woof!');
  }
}

class Cat {
  meow() {
    console.log('Meow!');
  }
}

function makeSound(animal: Dog | Cat) {
  if (animal instanceof Dog) {
    animal.bark();  // animal: Dog
  } else {
    animal.meow();  // animal: Cat
  }
}

Working with Built-in Classes

function processValue(value: Date | string) {
  if (value instanceof Date) {
    console.log(value.getFullYear());  // value: Date
  } else {
    console.log(value.toUpperCase());  // value: string
  }
}

in Operator

Checking property presence in object.

interface Circle {
  radius: number;
}

interface Square {
  size: number;
}

type Shape = Circle | Square;

function getArea(shape: Shape) {
  if ('radius' in shape) {
    // shape: Circle
    return Math.PI * shape.radius ** 2;
  } else {
    // shape: Square
    return shape.size ** 2;
  }
}

Checking Methods

interface Bird {
  fly(): void;
  layEggs(): void;
}

interface Fish {
  swim(): void;
  layEggs(): void;
}

function move(animal: Bird | Fish) {
  if ('fly' in animal) {
    animal.fly();  // animal: Bird
  } else {
    animal.swim();  // animal: Fish
  }
}

Equality Narrowing

Narrowing through equality check.

function process(x: string | number, y: string | boolean) {
  if (x === y) {
    // x and y can only be equal if both are string
    console.log(x.toUpperCase());  // x: string
    console.log(y.toUpperCase());  // y: string
  }
}

Checking for null and undefined

function printName(name: string | null | undefined) {
  if (name !== null && name !== undefined) {
    console.log(name.toUpperCase());  // name: string
  }
  
  // Or shorter
  if (name != null) {
    console.log(name.toUpperCase());  // name: string
  }
}

Truthiness Narrowing

Narrowing based on truthy/falsy check.

function printLength(str: string | null | undefined) {
  if (str) {
    // str: string (removed null and undefined)
    console.log(str.length);
  }
}

Falsy Values

function process(value: string | number | null | undefined | 0 | '') {
  if (value) {
    // value: string | number (removed falsy values)
    // BUT! 0 and '' are also falsy, so they're removed too
  }
}

Caution:

Truthiness narrowing removes ALL falsy values: 0, '', false, null, undefined, NaN.

More Precise Check

function processValue(value: string | null) {
  if (value !== null) {
    // value: string
    console.log(value.length);
  }
}

Type Predicates (is)

Custom type guards with is keyword.

function isString(value: unknown): value is string {
  return typeof value === 'string';
}

function process(value: unknown) {
  if (isString(value)) {
    // value: string
    console.log(value.toUpperCase());
  }
}

More Complex Checks

interface User {
  name: string;
  email: string;
}

function isUser(obj: unknown): obj is User {
  return (
    typeof obj === 'object' &&
    obj !== null &&
    'name' in obj &&
    'email' in obj &&
    typeof (obj as User).name === 'string' &&
    typeof (obj as User).email === 'string'
  );
}

function greetUser(data: unknown) {
  if (isUser(data)) {
    // data: User
    console.log(`Hello, ${data.name}!`);
  }
}

Discriminated Unions

Narrowing based on common discriminating property.

type Success = {
  status: 'success';
  data: string;
};

type Error = {
  status: 'error';
  message: string;
};

type Result = Success | Error;

function handleResult(result: Result) {
  if (result.status === 'success') {
    // result: Success
    console.log(result.data);
  } else {
    // result: Error
    console.log(result.message);
  }
}

Switch Statement

type Action =
  | { type: 'INCREMENT' }
  | { type: 'DECREMENT' }
  | { type: 'SET'; value: number };

function reducer(state: number, action: Action): number {
  switch (action.type) {
    case 'INCREMENT':
      // action: { type: 'INCREMENT' }
      return state + 1;
    
    case 'DECREMENT':
      // action: { type: 'DECREMENT' }
      return state - 1;
    
    case 'SET':
      // action: { type: 'SET'; value: number }
      return action.value;
  }
}

Assignment Narrowing

Narrowing on assignment.

let value: string | number;

value = 'hello';
// value: string (narrowed to string)

console.log(value.toUpperCase());

value = 42;
// value: number (narrowed to number)

console.log(value.toFixed(2));

Control Flow Analysis

TypeScript analyzes code execution flow.

function process(value: string | null) {
  if (value === null) {
    return;
  }
  
  // value: string (null excluded after return)
  console.log(value.toUpperCase());
}

Throw Statements

function assertIsString(value: unknown): asserts value is string {
  if (typeof value !== 'string') {
    throw new Error('Not a string!');
  }
}

function process(value: unknown) {
  assertIsString(value);
  
  // value: string (after assertion)
  console.log(value.toUpperCase());
}

Practical Examples

Handling API Response

type ApiResponse<T> = 
  | { success: true; data: T }
  | { success: false; error: string };

async function fetchUser(id: number): Promise<ApiResponse<User>> {
  // ...
}

const response = await fetchUser(1);

if (response.success) {
  // response: { success: true; data: User }
  console.log(response.data.name);
} else {
  // response: { success: false; error: string }
  console.error(response.error);
}

Validating Form Fields

interface FormData {
  name?: string;
  email?: string;
  age?: number;
}

function validateForm(data: FormData): boolean {
  if (!data.name) {
    console.error('Name is required');
    return false;
  }
  
  // data.name: string (not undefined)
  if (data.name.length < 3) {
    console.error('Name too short');
    return false;
  }
  
  if (!data.email) {
    console.error('Email is required');
    return false;
  }
  
  // data.email: string
  if (!data.email.includes('@')) {
    console.error('Invalid email');
    return false;
  }
  
  return true;
}

Working with Events

function handleEvent(event: MouseEvent | KeyboardEvent) {
  if (event instanceof MouseEvent) {
    console.log(`Mouse: ${event.clientX}, ${event.clientY}`);
  } else {
    console.log(`Key: ${event.key}`);
  }
}

Array.isArray()

function process(value: string | string[]) {
  if (Array.isArray(value)) {
    // value: string[]
    value.forEach(item => console.log(item));
  } else {
    // value: string
    console.log(value);
  }
}

Never Type and Exhaustiveness

type Shape = 
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; size: number };

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    
    case 'square':
      return shape.size ** 2;
    
    default:
      // shape: never (all cases handled)
      const _exhaustive: never = shape;
      return _exhaustive;
  }
}

If we add a new type, TypeScript will error:

type Shape = 
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; size: number }
  | { kind: 'triangle'; base: number; height: number };

// Error in default case!

Type Narrowing Limitations

Variable Changes in Callbacks

function process(value: string | null) {
  if (value !== null) {
    setTimeout(() => {
      // Error! value might have changed
      console.log(value.toUpperCase());
    }, 1000);
  }
  
  value = null;  // Changed!
}

Object Mutations

interface Container {
  value: string | number;
}

function process(container: Container) {
  if (typeof container.value === 'string') {
    setTimeout(() => {
      // Error! value could have changed
      console.log(container.value.toUpperCase());
    }, 0);
  }
}

Best Practices

Use Type Guards for Complex Checks

// Bad
function process(data: unknown) {
  if (
    typeof data === 'object' &&
    data !== null &&
    'name' in data &&
    typeof (data as any).name === 'string'
  ) {
    console.log((data as { name: string }).name);
  }
}

// Good
function isUser(data: unknown): data is { name: string } {
  return (
    typeof data === 'object' &&
    data !== null &&
    'name' in data &&
    typeof (data as any).name === 'string'
  );
}

function process(data: unknown) {
  if (isUser(data)) {
    console.log(data.name);
  }
}

Early Return for Simplification

// Bad
function process(value: string | null) {
  if (value !== null) {
    console.log(value.toUpperCase());
    console.log(value.length);
    // lots of code...
  }
}

// Good
function process(value: string | null) {
  if (value === null) return;
  
  // value: string throughout function
  console.log(value.toUpperCase());
  console.log(value.length);
  // lots of code...
}

Use Discriminated Unions

// Bad
interface Result {
  success: boolean;
  data?: string;
  error?: string;
}

// Good
type Result = 
  | { success: true; data: string }
  | { success: false; error: string };

Conclusion

Type Narrowing:

  • Automatic type refinement based on checks
  • typeof for primitives
  • instanceof for classes
  • in for object properties
  • Equality and truthiness narrowing
  • Type predicates (is) for custom checks
  • Discriminated unions for states
  • Control flow analysis tracks execution flow
  • Never for exhaustiveness checking

In Interviews:

Important to be able to:

  • Explain type narrowing concept
  • Show different narrowing methods (typeof, instanceof, in)
  • Write type guard with is
  • Explain discriminated unions
  • Show exhaustiveness checking via never
  • Discuss limitations (callbacks, mutations)