typescriptgenericsfunctional-programmingtype-inferencefp-ts

How to Automatically Infer Types in a TypeScript Functional Pipeline Using Higher-Order Transformer Functions?


I am building a simulator where an object is transformed step by step through various functions using pipe from fp-ts. To make the code more expressive, I'm using higher-order functions to create these transformers.

Here's an example of what I want my code to look like:

pipe(
    { name: "Nick" }, 
    merge({ age: 34 }), 
    merge({ job: "programmer" }), 
    merge({ married: true })
);

Ideally, I want this to be type-safe by specifying types for pipe, as shown below:

type Guy = { name: string };
type GuyWithAge = Guy & { age: number };
type GuyWithAgeAndJob = GuyWithAge & { job: string };
type GuyWithAgeAndJobAndMarried = GuyWithAgeAndJob & { married: boolean };

pipe<Guy, GuyWithAge, GuyWithAgeAndJob, GuyWithAgeAndJobAndMarried>(
    { name: "Nick" },
    merge({ age: 34 }), 
    merge({ job: "programmer" }), 
    merge({ married: true })
);

// results in { name: "Nick", age: 34, job: "programmer", married: true }

I want TypeScript to infer the types at each step, such that it only accepts the difference between the types, ensuring that the object transformation is type-safe.

I managed to implement this with generics and TypeScript utilities, but there's a caveat:

// Merge is like A & B but it overwrites properties on A with B. Like {...A, ...B}
type Merge<First extends object, Second extends object> = Omit<
  First,
  keyof Second
> &
  Second;

// A delta is the object that you merge with From to create To
export type Delta<From extends object, To extends object> = {
  [K in keyof To as K extends keyof From
    ? To[K] extends From[K]
      ? never
      : K
    : K]: To[K];
};

const merge =
  <First extends object, Second extends object>(
    delta: Delta<First, Second>,
  ) =>
  (obj: First): Merge<First, Delta<First, Second>> => {
    const merged = {
      ...obj,
      ...delta,
    };
    return merged;
  };

type Guy = { name: string };
type GuyWithAge = Guy & { age: number };
type GuyWithAgeAndJob = GuyWithAge & { job: string };
type GuyWithAgeAndJobAndMarried = GuyWithAgeAndJob & { married: boolean };

const guy = pipe<Guy, GuyWithAge, GuyWithAgeAndJob, GuyWithAgeAndJobAndMarried>(
  { name: "Nick" },
  merge<Guy, GuyWithAge>({ age: 34 }),
  merge<GuyWithAge, GuyWithAgeAndJob>({ job: "programmer" }),
  merge<GuyWithAgeAndJob, GuyWithAgeAndJobAndMarried>({ married: true })
);

The issue is that I need to explicitly specify the types each time I call merge, which leads to verbosity.

My question is: Is there any way to get TypeScript to automatically infer the correct generics for each merge call within the pipe, so that I don't have to manually specify the types at each step?

Edit

I’ve broken down the issue further. pipe expects a function of type A => B. When I pass merge<First, Second>(delta), it returns (obj: First) => Merge<First, Delta<First, Second>>.

This means I’m providing pipe with (obj: First) => Merge<First, Delta<First, Second>>, where it expects A => B. Thus, A should map to First, and B should map to Merge<First, Delta<First, Second>>, which ideally simplifies to Second (but perhaps this simplification is incorrect?).

I’m trying to understand why TypeScript can’t infer that A corresponds to First and B corresponds to Second, which would eliminate the need to pass generics to merge. My aim is to understand TypeScript’s inference mechanism better and/or fix the code so that the inference works as expected.


Solution

  • Right now you're expecting TypesScript to be able to match a type like (arg: Guy) => GuyWithAge to the type <F, S>(d: Delta<F, S>) => (o: F) => Merge<F, Delta<F, S>> called on a value of type {age: number} and from this, infer F and S to be Guy and GuyWithAge respectively. This is too much for TypeScript to do. While F appears fairly straightforwardly in that type, the S type is buried within both Delta and Merge, which are implemented with conditional types.

    TypeScript's inference works best when the types you need to infer are very directly related to the types it has to work with. As a first step here, I'd say that if you expect Merge<F, Delta<F, S>> to always be equivalent to S, then you should probably just use the type <F, S>(d: Delta<F, S>) => (o: F) => S directly, and that will give TypeScript more of a chance to infer S properly:

    const merge =
      <F extends object, S extends object>(
        delta: Delta<F, S>,
      ) =>
        (obj: F): S => {
          const merged = {
            ...obj,
            ...delta,
          };
          return merged as any; // <-- just assert this
        };
    

    Yes, we need to assert that {...obj, ...delta} produces a value of type S, because TypeScript can't follow how Omit and Delta work for arbitrary generic types. This is basically the missing feature described at microsoft/TypeScript#28884. There are edge cases in which Merge<F, Delta<F, S>> and S are not equivalent (e.g., when S is a callable type) but I'm not going to worry about those here; I'd advise not worrying about them until and unless they show up in use cases you care about.

    Anyway, once we make that change then you get the inference you're looking for:

    type Guy = { name: string };
    type GuyWithAge = Guy & { age: number };
    type GuyWithAgeAndJob = GuyWithAge & { job: string };
    type GuyWithAgeAndJobAndMarried = GuyWithAgeAndJob & { married: boolean };    
    const guy = pipe<Guy, GuyWithAge, GuyWithAgeAndJob, GuyWithAgeAndJobAndMarried>(
      { name: "Nick" },
      merge(({ age: 34 })), // okay
      merge({ job: "programmer" }), // okay
      merge({ married: true }) // okay
    );
    

    That's the answer to the question as asked, and it meets the needs of the person asking the question. Future readers may have different use cases:

    Having merge() require F and S up front when you call it only giving it a value of type Delta<F, S> is limiting, since it makes inference very difficult. If you call merge({a: 123}) independently of pipe(), you'll currently get a type like (x: object) => object, which isn't useful. More useful in general would be to have the function be directly generic in the type of its input, and have the returned function be directly generic in the type of its input. This makes inference and control flow work together instead of fighting with each other:

    const merge =
      <U extends object>(delta: U) =>
        <T extends object>(obj: T) => {
          const merged = { ...obj, ...delta };
          return merged as Merge<T, U>;
        };
    

    Now TypeScript would only need to infer from the call sites directly. If you call merge({a: 123}) you get a return type of <T extends object>(obj: T) => Merge<T, {a: number}>, which lets you re-use that multiple times and get multiple outputs. And then when you call pipe() you don't need to specify types manually at all:

    const x = pipe(
      { a: 123 },
      merge({ b: "xyz" }),
      merge({ c: true }),
      merge({ d: new Date() })
    )
    /* const x: {
      a: number;
      b: string;
      c: boolean;
      d: Date;
    } */
    

    If you do want to manually specify types, you can, but it's not necessary. And again, the asker of this question doesn't apparently care about this use case, and is much more concerned about excess property checking, which only happens in some circumstances in TypeScript and is quite fragile, see Enforce type checking for object created by spreading. For future readers, however, there may be some benefit in the more direct types shown here.

    Playground link to code