🔷 TypeScript Q77 / 101

What is advanced narrowing?

AI-Powered Answer ✓ Answered

Advanced narrowing in TypeScript refers to techniques that allow the TypeScript compiler to infer more specific types for variables within certain code blocks, going beyond basic `typeof` or `instanceof` checks. These techniques enable more robust type safety and improve code readability, especially when dealing with complex union types, object structures, or custom type checks.

What is Narrowing?

Narrowing is the process by which TypeScript analyzes runtime checks and statements to refine the type of a variable from a broader type (like a union type) to a more specific one within a certain scope. For example, checking typeof value === 'string' narrows value to string within the if block.

Why 'Advanced' Narrowing?

While basic narrowing with typeof, instanceof, equality checks, and truthiness checks is powerful, it often falls short when dealing with custom types, interfaces, or complex object structures. Advanced narrowing techniques provide mechanisms to perform more intricate type refinement based on custom logic or specific property existence, enabling TypeScript to understand relationships that it can't infer purely from built-in operators.

Key Advanced Narrowing Techniques

  • Type Predicates
  • Discriminated Unions
  • in Operator Narrowing
  • asserts Type Guards (Assertion Functions)

1. Type Predicates

Type predicates are custom user-defined type guard functions that tell the TypeScript compiler whether a value is of a specific type. They return a special type predicate signature in the form parameterName is Type.

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

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

function isBird(pet: Bird | Fish): pet is Bird {
  return (pet as Bird).fly !== undefined;
}

let pet: Bird | Fish = {
  fly: () => console.log("flying"),
  layEggs: () => console.log("laying eggs")
};

if (isBird(pet)) {
  pet.fly(); // TypeScript knows pet is Bird here
}

In this example, isBird acts as a type guard. When it returns true, TypeScript narrows the type of pet to Bird within the if block, allowing access to fly() without casting.

2. Discriminated Unions

Discriminated unions combine union types with a common literal property (the 'discriminant') that allows TypeScript to narrow down the specific member of the union. By checking the value of the discriminant property, TypeScript can infer the precise type.

typescript
interface Circle {
  kind: "circle";
  radius: number;
}

interface Square {
  kind: "square";
  sideLength: number;
}

interface Triangle {
  kind: "triangle";
  base: number;
  height: number;
}

type Shape = Circle | Square | Triangle;

function getArea(shape: Shape) {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2; // shape is Circle here
    case "square":
      return shape.sideLength ** 2; // shape is Square here
    case "triangle":
      return 0.5 * shape.base * shape.height; // shape is Triangle here
    default:
      // exhaustive check for safety
      const _exhaustiveCheck: never = shape;
      return _exhaustiveCheck;
  }
}

let myShape: Shape = { kind: "square", sideLength: 10 };
console.log(getArea(myShape));

Here, kind is the discriminant. Within each case statement, TypeScript automatically narrows shape to the corresponding interface (Circle, Square, or Triangle), providing type-safe access to their unique properties. The default case with _exhaustiveCheck ensures all union members are handled.

3. `in` Operator Narrowing

The in operator checks if an object has a certain property. TypeScript uses this check to narrow the type of the object based on the presence or absence of that property.

typescript
interface Car {
  drive(): void;
}

interface Boat {
  sail(): void;
}

type Vehicle = Car | Boat;

function operateVehicle(vehicle: Vehicle) {
  if ("drive" in vehicle) {
    vehicle.drive(); // vehicle is Car here
  } else {
    vehicle.sail(); // vehicle is Boat here
  }
}

let myCar: Vehicle = { drive: () => console.log("driving car") };
operateVehicle(myCar);

By checking for the existence of the drive property using in, TypeScript is able to determine that vehicle must be a Car within the if block and a Boat in the else block (assuming Vehicle only has Car and Boat as options with distinct properties).

4. `asserts` Type Guards (Assertion Functions)

Assertion functions are a specialized type of function that asserts something about its input. If the assertion fails (e.g., a condition is not met), the function is expected to throw an error. If it returns normally, TypeScript understands that the asserted condition is true for the remainder of the scope. They use the asserts parameterName is Type or asserts condition signature.

typescript
function assertIsString(val: any): asserts val is string {
  if (typeof val !== "string") {
    throw new Error("Not a string!");
  }
}

function processValue(value: unknown) {
  assertIsString(value); // If this doesn't throw, value is string
  console.log(value.toUpperCase()); // TypeScript knows value is string here
}

processValue("hello");
// processValue(123); // Throws "Not a string!"

The assertIsString function asserts that val is a string. If it completes without throwing, TypeScript narrows the type of value to string in subsequent code. This is useful for pre-condition checks, similar to how many internal library functions might validate their inputs.

Conclusion

Advanced narrowing techniques are crucial for writing robust, type-safe, and maintainable TypeScript code, especially when dealing with complex data structures and polymorphic functions. They empower the compiler to understand more intricate type relationships, leading to fewer runtime errors and a better development experience.