I'm trying to narrow a generic, where the autocomplete picks up on that it's hitting a typeguard, and thus won't reach that same block of code again. I think the reason is that I'm not constraining the type to the Generic, but im not sure how i would do that, is it even possible? It feels like it should be possible but im unsure. Any help would be appreciated.
// Setup
export type Feature<Geometry> = {
type: 'Feature',
geometry: Geometry
}
type Geometry = Point | Curve
interface Base {
type: string
}
interface Point extends Base{
type: 'Point'
}
interface Curve extends Base {
type: 'Curve'
}
// Typeguard
function isGeometry<G extends Geometry, U extends G['type']>(geometry: G, disciminator: U): geometry is Extract<G, {type: U}>{
return geometry.type === disciminator
}
function isFeature<G extends Geometry, U extends G['type']>(feature: Feature<G>, disciminator: U): feature is Feature<Extract<G, {type: U}>> {
return feature.geometry.type === disciminator
}
function whatGeometry(feature: Feature<Point | Curve>) {
if(isGeometry(feature.geometry, 'Curve')){
return feature.geometry;
// ^?
}
if(isGeometry(feature.geometry, 'Point')){
return feature.geometry;
// ^?
} // Autocompletes, and knows that we can't have anything else for a geometry,
return;
}
function whatFeature(feature: Feature<Point | Curve>) {
if(isFeature(feature, 'Curve')){
return feature.geometry;
// ^?
}
if(isFeature(feature, 'Point')) {
return feature;
// ^?
} // Assumes we can have another Feature<Point> even though the upper typeguard should have caught it
return;
}
The main issue you're facing is that user-defined type guard functions like isFeature
only let you specify exactly what should happen if they return true
, which is to narrow the relevant argument's type to the guarded type. So for a function whose return type is arg is Type
, we know that for a true
result, arg
will be narrowed to something like typeof arg & Type
, or Extract<typeof arg, Type>
(using the Extract
utility type), depending on what type arg
has. Roughly, if typeof arg
is a union type then you get the Extract
-like behavior; otherwise you get the intersection-like behavior. (I'm saying "like" a lot here because the actual behavior is a bit more subtle and I don't want to digress too much.)
But when the function returns false
, the compiler has to try to determine what to do by itself. There are no "one-sided" or "fine-grained" type guard functions as requested in microsoft/TypeScript#15048 that would let you say arg is TrueType else FalseType
or the like. Right now what happens is, if arg
has a union type, then you get something like Exclude<typeof arg, Type>
, which actually filters arg
. But if arg
does not have a union type, then you just get typeof arg
. TypeScript lacks negated types like not Type
, so there is no type like typeof arg & not Type
analogous to typeof arg & Type
that you can narrow to in the false
case. (There was an implementation of negated types at microsoft/TypeScript#29317 but it was never adopted.) So the best the compiler can do in general is to leave arg
alone.
That leads directly to the behavior you're seeing with isFeature
. The input type Feature<Point | Curve>
is not a union type, so when isFeature
returns false, there's not much the compiler can do to the type of the input.
If you want to be able to narrow on the false
branch, you should consider making the input type a union like Feature<Point> | Feature<Curve>
. It's more complicated to do this, since your current isFeature()
would balk at an input of that type. But you could refactor to allow union inputs, possibly like this:
function isFeature<T extends Geometry['type'], U extends T>(
feature: Feature<Geometry & { type: T }>, disciminator: U
): feature is Feature<Geometry & { type: U }> {
return feature.geometry.type === disciminator
}
function whatFeature(feature: Feature<Point> | Feature<Curve>) {
if (isFeature(feature, 'Curve')) {
return feature.geometry;
// ^?(parameter) feature: Feature<Curve>
}
feature;
// ^?(parameter) feature: Feature<Point>
if (isFeature(feature, 'Point')) {
return feature;
// ^?(parameter) feature: Feature<Point>
}
return;
}
So feature
is Feature<Point> | Feature<Curve>
, making it possible for a negative type guard result to narrow it. And isFeature()
is generic in the type
T
which allows union inputs. When it returns true
, then the compiler narrows the input to just those union members whose geometry type is U
, and so a false
result narrows the input to the complement of those members.
That gives you the behavior you want, where feature
is narrowed to Feature<Point>
after a negative check for isFeature(feature, 'Curve')
, as desired.