Search code examples
typescriptdesign-patternsfactory

Generic factory typings


There is simple factory and I don't understand why typescript produces error at main function createFeature.

How would be proper to describe types?

interface Feature {
    id: string;
    kind: string;
}

export function create<Kind extends string, Fields extends Record<any, any>>(
  kind: Kind,
  options: Fields,
): Feature & { kind: Kind } & Fields {
  return {
    id: '',
    kind,
    ...options,
  };
}

interface PointOptions {
    coordinate: [number, number];
}

interface Point extends Feature {
    x: number;
    y: number;
}

function createPoint({ coordinate: [x, y] }: PointOptions): Point {
    return create('point', { x, y });
}

interface LineOptions {
    waypoints: [number, number][];
}

interface Line extends Feature {
    coordinates: { x: number; y: number }[];
}

function createLine(options: LineOptions): Line {
    return create('line', {
        coordinates: options.waypoints.map(([x, y]) => ({ x, y })),
    });
}

const features = {
    point: createPoint,
    line: createLine,
} as const;

type Library = typeof features;
export type FeatureKey = keyof Library;
export type FeatureOptions<Type extends FeatureKey> = Library[Type] extends (
  options: infer Options,
) => any
  ? Options
  : never;
export type FeatureReturn<Type extends FeatureKey> = Library[Type] extends (
  options: any,
) => infer ConcreteFeature
  ? ConcreteFeature
  : never;

function createFeature<Type extends FeatureKey>(
  type: Type,
  options: FeatureOptions<Type>,
): FeatureReturn<Type> {
  if (!(type in features)) {
    throw new Error('Unknown feature type');
  }
  return features[type](options);
}

Errors

Type 'Point | Line' is not assignable to type 'FeatureReturn<Type>'.
  Type 'Point' is not assignable to type 'FeatureReturn<Type>'.

Argument of type 'FeatureOptions<Type>' is not assignable to parameter of type 'PointOptions & LineOptions'.
  Type 'unknown' is not assignable to type 'PointOptions & LineOptions'.
    Type 'unknown' is not assignable to type 'PointOptions'.
      Type 'FeatureOptions<Type>' is not assignable to type 'PointOptions'.
        Type 'unknown' is not assignable to type 'PointOptions'.

Code snippet


Solution

  • In order for something like features[type](options) to type check, the compiler needs to understand that features[type] is a single function type that accepts a single argument of the same type as typeof options. In your code, feature[type] is seen as a generic indexed access type into typeof features, but the compiler doesn't see any single type it can assign to it. So it ends up being widened to the union type ((o: PointOptions) => Point) | ((o: LineOptions) => Line) which cannot be safely called unless its input is both a PointOptions and a LineOptions. But that's not what typeof options is, so it fails. This is essentially a failure of TypeScript to directly support what I've been calling "correlated unions", as described in microsoft/TypeScript#30581. If feature[type] is a union, then so is options, and these unions are correlated and not independent. But the compiler fails to see it.

    Luckily there is an approach that can lead the compiler to see the code as type safe, and it's described in microsoft/TypeScript#47109. The idea is to represent everything in terms of some basic key-value interface types, or mapped types over them, and then do generic indexes into these types. This is actually somewhat close to what you're doing already, with the major exception that features itself is not written as a mapped type. Here's are the fixes you need:

    First, we should rename features out of the way so that we can later change its apparent type to a more compiler-friendly version:

    const _features = {
      point: createPoint,
      line: createLine,
    } as const;
    
    type Library = typeof _features;
    

    Then, your FeatureOptions and FeatureReturn types can be rewritten as full object types with the same keys as typeof _features:

    type FeatureKey = keyof Library;
    
    type FeatureOptions = { [K in FeatureKey]: Library[K] extends (
      options: infer O) => any ? O : never; }
    
    type FeatureReturn = { [K in FeatureKey]: Library[K] extends (
      options: any) => infer R ? R : never; }
    

    And finally, we write features as a mapped type that explicitly relates FeatureOptions to FeatureReturn:

    const features: { [K in FeatureKey]:
      (options: FeatureOptions[K]) => FeatureReturn[K] } = _features;
    

    All of this looks like a big no-op, since the old type of features and the new type are equivalent. But the compiler does not see them as identical. The compiler cannot "see" the abstraction over K for the first version. For the new version, now the compiler will understand that features[type] will be of type (options: FeatureOptions[K]) => FeatureReturn[K] if type is of type K, and thus it's a single function type that can be called.

    Like this:

    function createFeature<K extends FeatureKey>(
      type: K,
      options: FeatureOptions[K],
    ): FeatureReturn[K] {
      return features[type](options); // okay
    }
    

    Again, it's not like your original version was wrong, it's just that the compiler didn't understand what you were doing. For more information about the general issue and the approach taken here, please see the GitHub issues linked above.

    Playground link to code