Search code examples
typescriptgenericstypescript-genericstype-inferencetypeguards

possible to narrow a generic with typeguard?


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;
}

Playground


Solution

  • 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.

    Playground link to code