Search code examples
typescriptclassgenericstypescript-genericstypeguards

Why doesn't TypeScript narrow the generic type with user-defined type guards in a class?


I am trying to implement a DatePicker class in TypeScript that supports two modes:

  • 'single' mode for selecting a single date.
  • 'range' mode for selecting a date range.

Here’s my type definition and initial implementation:

type SingleValue = Date | null;
type RangeValue = SingleValue[] & { length: 2 };

type DatePickerValue<T extends 'single' | 'range'> = T extends 'single' ? SingleValue : RangeValue;

class DatePicker<T extends 'single' | 'range'> {
  type: T;
  value: DatePickerValue<T>;

  constructor(type: T, initialValue: DatePickerValue<T>) {
    this.type = type;
    this.value = initialValue;
  }

  updateValue(date: Date) {
    // Update value logic...
  }
}

const singleDatePicker = new DatePicker('single', new Date('2025-01-01'));
singleDatePicker.value = new Date('2025-01-15'); // Works as expected
singleDatePicker.value = null; // Works as expected

const rangeDatePicker = new DatePicker('range', [new Date('2025-01-01'), new Date('2025-01-05')]);
rangeDatePicker.value = [new Date('2025-01-03'), new Date('2025-01-10')]; // Works as expected
rangeDatePicker.value = [new Date('2025-01-03'), null]; // Works as expected
rangeDatePicker.value = [null, new Date('2025-01-10')]; // Works as expected
rangeDatePicker.value = [null, null]; // Works as expected

The value property is correctly inferred based on the generic type T, and the class works as expected so far.

I then added an updateValue method to update the value property when the user selects a new date. Since the behavior of this method depends on the type ('single' or 'range'), I tried narrowing the type of this using user-defined type guards.

Here’s my implementation of the updateValue method:

class DatePicker<T extends 'single' | 'range'> {
  // Other code...

  isSingle(): this is DatePicker<'single'> {
    return this.type === 'single';
  }

  isRange(): this is DatePicker<'range'> {
    return this.type === 'range';
  }

  updateValue(date: Date) {
    if (this.isSingle()) {
      this.value = date;
      // Error: Type 'Date' is not assignable to type 'DatePickerValue<T> & SingleValue'.
    }

    if (this.isRange()) {
      this.value = [date, null];
      // Error: Type '[Date, null]' is not assignable to type 'DatePickerValue<T> & SingleValue[] & { length: 2; }'.
    }
  }
}

I defined user-defined type guard methods isSingle and isRange to narrow the generic type T in the updateValue method. These methods check the value of this.type and return the corresponding type for this.

I expected TypeScript to infer the type of this.value as SingleValue if this.type is 'single', and as RangeValue if this.type is 'range'. However, TypeScript still throws the following errors:

  1. For the 'single' case:

    Type 'Date' is not assignable to type 'DatePickerValue<T> & SingleValue'.
    
  2. For the 'range' case:

    Type '[Date, null]' is not assignable to type 'DatePickerValue<T> & SingleValue[] & { length: 2; }'.
    

My Question:

Why doesn't TypeScript narrow the generic type DatePickerValue<T> using user-defined type guards, and how can I achieve the intended behavior?


Solution

  • Your this is type predicate is guarding against, say, DatePicker<"single"> which is not known to already be a subtype of DatePicker<T> for arbitrary generic T.

    Your intent with T is presumably that it should be exactly one of "single" or "range", but the constraint T extends "single" | "range" doesn't work that way. TypeScript also allows that it could be both of them (the full union type) or neither of them (the never type). So instead of treating T like a single specific type, it stubbornly stays generic. Type guards narrow values, they don't re-constrain generic type parameters.

    There's a longstanding open feature request at microsoft/TypeScript#27808 to be able to have a "one of" constraint, and if that were implemented, presumably TypeScript would treat this as effectively DatePicker<"single"> | DatePicker<"range">. But for now it doesn't, and T stays generic.

    And that's a problem, because when TypeScript applies a type guard of the form x is Y when it isn't sure if typeof x is assignable to Y, it ends up narrowing x to the intersection (typeof x) & Y. In your case that ends up looking like DatePicker<T> & DatePicker<"single">, and you can't assign anything to its value property, because that type would have to be (T extends 'single' ? SingleValue : RangeValue) & SingleValue, and without T being specific, TypeScript imagines that you might have RangeValue & SingleValue. This is the crux of the issue you're running into, and for now, you'll just have to work around it.


    I'd say the easiest workaround is to treat this inside the method as being of the specific (non-generic) type DatePicker<"single"> | DatePicker<"range">, which is going to be true in practice. And you can do that via a this parameter:

    updateValue(this: DatePicker<'single'> | DatePicker<'range'>, date: Date) {
    
        if (this.isSingle()) {
            this.value = date; // okay
        }
    
        if (this.isRange()) {
            this.value = [date, null]; // okay
        }
    

    A this parameter will only allow calls to succeed if the object on which the method is called is of the expected type, but that's not a problem for use cases where that object is not generic:

    const singleDatePicker = new DatePicker('single', new Date('2025-01-01'));
    //    ^? const singleDatePicker: DatePicker<"single"> // not generic
    singleDatePicker.updateValue(new Date()); // okay
    
    const rangeDatePicker = new DatePicker('range', [new Date('2025-01-01'), new Date('2025-01-05')]);
    //    ^? const rangeDatePicker: DatePicker<"range"> // not generic
    rangeDatePicker.updateValue(new Date()); // okay
    
    const either = Math.random() < 0.5 ? singleDatePicker : rangeDatePicker;
    //    ^? const either: DatePicker<"single"> | DatePicker<"range"> // not generic
    either.updateValue(new Date()); // okay
    

    You do run into problems if that obect is generic:

    function foo<T extends "single" | "range">(generic: DatePicker<T>) {
        generic.updateValue(new Date()); // error!
        //~~~~~ <-- The 'this' context of type 'DatePicker<T>' is not assignable to method's 
        // 'this' of type 'DatePicker<"single"> | DatePicker<"range">'.
    }
    

    but I don't know how important that is to you.


    There are other approaches like using DatePicker<'single' | 'range'> instead of DatePicker<'single'> | DatePicker<'range'>, but that might err on the side of allowing unsafe things instead of disallowing safe things. Or you could just use type assertions inside the updateValue() method to assign this to a specific type wherever you want to.

    The point is that you will need to work around the problem by using specific types instead of generic types where needed, because TypeScript does not re-constrain generic type parameters based on type guards. (In TypeScript 5.8, microsoft/TypeScript#56941 is expected to land, which will actually perform some generic re-constraining based on type guards, but there are a lot of restrictions so it won't work directly for your use case. Maybe there's some refactoring that would take advantage of it, but since the feature isn't even released yet I'm not going to try to use it here.)

    Playground link to code