typescriptgeojsontypeguardstype-narrowing

Generic Typeguard for Inner type


so i've got some issues, at work we use these types:

export type GeoJSON = Geometry | Feature;

export type GeoJsonTypes = GeoJSON['type'];

export interface GeoJsonObject { type: GeoJsonTypes; }

export interface Point extends GeoJsonObject {
   type: 'Point';
   coordinates: number[];
 }

export interface MultiPoint extends GeoJsonObject {
   type: "MultiPoint",
   coordinates: number[][];
 }

export type Geometry =
 | Point
 | MultiPoint

export interface Feature<G extends Geometry = Geometry>
  extends GeoJsonObject {
  type: 'Feature';
  geometry: G;
}

The issue that we're having is that we end up having to create a Typeguard for each Feature at the callsites when we have Feature<Geometry> that we want to narrow.

export function isPolygon(f: Feature): f is Feature<Polygon> {
  return f && f.geometry && f.geometry.type === 'Polygon';
}

I want to make a generic typeguard, i used Generic Typeguard as base for my thinking but i'm stuck.

I know that i can use the Extract<Geometry, {type: U}> to get my type such as this example.

export function isFeature<T extends Feature<Geometry>, U extends T['geometry']['type']>(
  feature: T,
  type: U
): feature is Feature<Extract<Geometry, { type: U }>> {
  return feature && feature.geometry && feature.geometry.type === type;
}

The problem that i'm having is that if i constrain T to be of type Feature, i have no use for the T variable in the expression, it gives me an error saying A type Predicate's type must be assignable to it's parameter type, If i constrain it to Geometry, then i can't use Feature<Extract<T, {type: U}>> Cause T could be instantiated with an arbitrary type which could be unrelated, is there any way to have the generic typeguard for a feature?


Solution

  • The issue is that in the type guard you type feature as T and you are checking whether feature is something else other than T, which is logically impossible to convert T to Feature<Extract<Geometry, { type: U }>>. Instead, you should only accept U and keep the feature nongeneric, just unknown.

    isFeature<U extends Feature<Geometry>["geometry"]["type"]>(
      feature: unknown,
      type: U
    ): feature is Feature<Extract<Geometry, { type: U }>> {}
    

    This will cause many repetitive checks and to avoid them we can create a generic isObject type guard that would turn any object type T to Partial<Record<keyof T, unknown>>. The reason to turn properties to unknown and make the whole object partial is to make it as type-safe as possible:

    const isObject = <T>(
      arg: unknown
    ): arg is Partial<Record<keyof T, unknown>> => {
      return !!arg && typeof arg === "object" && !Array.isArray(arg);
    };
    

    Next, we can extract a type guard for generic features, ideally you would also include the geometry.coordinates into checking:

    const isFeatureBase = (arg: unknown): arg is Feature => {
      return (
        isObject<Feature>(arg) &&
        isObject<Feature["geometry"]>(arg.geometry) &&
        typeof arg.type === "string" &&
        typeof arg.geometry.type === "string"
      );
    };
    

    Finally, our isFeature will look like this:

    function isFeature<U extends Feature<Geometry>["geometry"]["type"]>(
      feature: unknown,
      type: U
    ): feature is Feature<Extract<Geometry, { type: U }>> {
      return isFeatureBase(feature) && feature.geometry.type === type;
    }
    

    Usage:

    const a = {};
    if (isFeature(a, "MultiPoint")) {
      // "MultiPoint"
      a.geometry.type;
    }
    

    playground