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:
For the 'single'
case:
Type 'Date' is not assignable to type 'DatePickerValue<T> & SingleValue'.
For the 'range'
case:
Type '[Date, null]' is not assignable to type 'DatePickerValue<T> & SingleValue[] & { length: 2; }'.
Why doesn't TypeScript narrow the generic type DatePickerValue<T>
using user-defined type guards, and how can I achieve the intended behavior?
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.)