Skip to main content

07-10-edge-cases-in-type-narrowing

Edge Cases in Type Narrowing in TypeScript:

Let's proceed with the concise code examples for the TypeScript key points you've highlighted:

  • Type Guards Might Not Persist:

    Within nested functions or callbacks, TypeScript might not preserve type narrowing. This can cause a variable to revert to a broader type.

    • Example:
      function doSomething(value: string | number) {
      if (typeof value === 'string') {
      // TypeScript knows value is a string here
      return function() {
      // Inside this function, TypeScript might not remember 'value' is a string
      console.log(value.trim()); // Error if 'value' is not a string
      };
      }
      }
  • Narrowing with typeof:

    When using typeof for type guards, TypeScript only narrows to string, number, bigint, boolean, symbol, undefined, object, and function. It won't recognize custom types.

    • Example:
      function processValue(value: unknown) {
      if (typeof value === 'string') {
      // TypeScript narrows 'value' to string
      console.log(value.toUpperCase());
      }
      }
  • Narrowing with instanceof:

    The instanceof type guard only works with classes or functions created with a constructor. It doesn't work with interfaces since they don't exist at runtime.

    • Example:
      class MyClass {}
      function checkInstance(object: any) {
      if (object instanceof MyClass) {
      // TypeScript narrows 'object' to MyClass instance
      console.log('It is an instance of MyClass');
      }
      }
  • Nullish Coalescing Operator (??) and Optional Chaining (?.):

    These operators can introduce unexpected behaviors when used in type narrowing because they handle null and undefined differently than other falsy values.

    • Example:

      type MaybeNumber = {
      num?: number | null;
      };

      const example: MaybeNumber = { num: null };
      const number = example.num ?? 10; // 'number' is type 'number', but TypeScript doesn't narrow 'example.num' to 'number'
  • Narrowing and Union Types:

    When narrowing union types, be cautious of types that have common properties, as TypeScript may not narrow the type down to a single possibility.

    • Example:

      type Cat = { meow: boolean };
      type Dog = { bark: boolean };
      type Pet = Cat | Dog;

      function petSounds(pet: Pet) {
      if ('meow' in pet) {
      // TypeScript knows 'pet' could be Cat, but it's still Pet because Dog might also have 'meow'
      console.log('It may meow');
      }
      }
  • Array includes Method:

    Using .includes() to narrow types from a union can be tricky because TypeScript does not infer that the element is of a specific type from the union.

    • Example:

      const pets = ['cat', 'dog', 'parrot'] as const;
      type Pet = typeof pets[number]; // 'cat' | 'dog' | 'parrot'

      function isCat(pet: Pet) {
      if (pets.includes(pet)) {
      // TypeScript can't infer 'pet' is specifically 'cat'
      console.log('It is a pet', pet);
      }
      }
  • Discriminated Unions:

    Relying on a common property (discriminant) is a robust way to narrow union types, but only if the discriminant's values are unique across members.

    • Example:

      ```typescript
      type Square = { kind: 'square'; size: number };
      type Circle = { kind: 'circle'; radius: number };
      type Shape = Square | Circle;

      function getArea(shape: Shape) {
      if (shape.kind === 'square') {
      // TypeScript narrows 'shape' to Square
      return shape.size * shape.size;
      }
      }
      ```

      Let's continue with the code examples for the remaining TypeScript key points:

  • Type Narrowing with Custom Type Guards:

    Custom type guards (is keyword) can precisely narrow types, but they need to be correctly defined to avoid runtime errors.

    • Example:

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

      function processValue(value: any) {
      if (isString(value)) {
      // TypeScript narrows 'value' to string based on the custom type guard
      console.log(value.toUpperCase());
      }
      }
  • Narrowing with Enums:

    Type narrowing with enums can be unpredictable if the enum is number-based because TypeScript might also accept raw numbers where the enum is expected.

    • Example:

      enum Status {
      New,
      InProgress,
      Done,
      }

      function processStatus(status: Status) {
      if (status === Status.Done) {
      // TypeScript narrows 'status' to Status.Done
      console.log('Completed');
      }
      }

      processStatus(2); // Works, but passing any number would also work, which can be unsafe
  • Aliasing Primitive Types:

    When primitive types are aliased, TypeScript may not narrow them as expected because the alias could technically be a different type.

    • Example:
      type MyStringType = string;
      function isString(value: MyStringType | number) {
      // Even though MyStringType is an alias for string, TypeScript treats it as a unique type
      if (typeof value === 'string') {
      // TypeScript does not narrow 'value' to 'MyStringType'
      console.log('It is a string');
      }
      }
  • Assertions and Narrowing:

    Assertions (as keyword) can interfere with type narrowing, as they forcefully tell the compiler to treat an entity as a certain type.

    • Example:
      function forceString(value: any) {
      const strValue = value as string;
      // TypeScript is told to assume 'strValue' is a string, even if it's not
      console.log(strValue.trim());
      }
  • Function Overloads:

    TypeScript looks at function overloads from top to bottom. Incorrectly ordered overloads can affect type narrowing.

    • Example:
      function overloadedFunction(value: string): void;
      function overloadedFunction(value: number): void;
      function overloadedFunction(value: any): void {
      if (typeof value === 'string') {
      // If the string overload is above the number overload, TypeScript will narrow to string here
      console.log('String:', value);
      }
      }
  • Literal Types and Narrowing:

    When dealing with literal types, TypeScript might widen the literal to its base type unless you use const assertions.

    • Example:
      let literalType = 'specificValue' as const; // 'specificValue' is treated as a literal type, not string
      function checkLiteral(value: 'specificValue' | 'otherValue') {
      if (value === literalType) {
      // TypeScript recognizes 'value' as 'specificValue'
      }
      }
  • Control Flow Analysis:

    TypeScript's control flow can narrow types within blocks like if statements, but these narrowed types may not be recognized outside the block.

    • Example:
      function checkType(value: string | number) {
      if (typeof value === 'string') {
      // Inside the if block, 'value' is narrowed to string
      console.log(value.toUpperCase());
      }
      // Outside the if block, 'value' is still string | number
      }
  • Intersection Types and Narrowing:

    Narrowing can behave unexpectedly with intersection types if not all parts of the intersection are valid for all members of the type.

    • Example:
      type A = { a: number };
      type B = { b: string };
      function useIntersection(value: A & B) {
      if ('a' in value) {
      // 'value' is narrowed to A & B, not just A
      console.log(value.a, value.b);
      }
      }
  • Type Predicates in Interfaces and Type Aliases:

    While you can use type predicates in interfaces and type aliases, they can lead to issues if not every implementation of the interface or every alias satisfies the predicate.

    • Example:

      interface Bird {
      fly: () => void;
      }

      interface Fish {
      swim: () => void;
      }

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