Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve reverse mapped types inference by creating candidates from concrete index types #51612

Open
5 tasks done
Andarist opened this issue Nov 21, 2022 · 5 comments Β· May be fixed by #53017
Open
5 tasks done

Improve reverse mapped types inference by creating candidates from concrete index types #51612

Andarist opened this issue Nov 21, 2022 · 5 comments Β· May be fixed by #53017
Labels
Experimentation Needed Someone needs to try this out to see what happens Suggestion An idea for TypeScript
Milestone

Comments

@Andarist
Copy link
Contributor

Andarist commented Nov 21, 2022

Suggestion

πŸ” Search Terms

inference, reverse mapped types, schema

βœ… Viability Checklist

My suggestion meets these guidelines:

  • This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • This wouldn't change the runtime behavior of existing JavaScript code
  • This could be implemented without emitting different JS based on the types of the expressions
  • This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, new syntax sugar for JS, etc.)
  • This feature would agree with the rest of TypeScript's Design Goals.

⭐ Suggestion

It would be great if TypeScript could take into account concrete index types when inferring reverse mapped types.

Reverse mapped types are a great technique that allows us to create dependencies between properties in complex objects.

For example, in here we can validate what strings can be used as initial property on any given level of this object. We can also "target" sibling keys (from the parent object) within the on property.

This type of inference starts to break though once we add a constraint to T in order to access some of its known properties upfront. Things like T[K]["type"] prevents T from being inferred because the implemented "pattern matching" isn't handling this case and without a special handling this introduces, sort of, a circularity problem. Note how this doesn't infer properly based on the given argument: here

I think there is a great potential here if we'd consider those accessed while inferring.

πŸ“ƒ Motivating Example

interface QueryFunctionContext<
  TQueryKey extends string,
> {
  queryKey: TQueryKey
}

type QueryOptions = {
    key: string
    data?: unknown;
    fnData?: unknown;
  }

type UseQueriesOptions<T extends ReadonlyArray<QueryOptions>> = {
  [K in keyof T]: {
    queryKey: T[K]['key']
    queryFn?: (
      ctx: QueryFunctionContext<T[K]['key']>,
    ) => Promise<T[K]['fnData']> | T[K]['fnData']
    select?: (data: T[K]['fnData']) => T[K]['data']
  }
}

declare function useQueries<
  T extends ReadonlyArray<QueryOptions>
>(queries: [...UseQueriesOptions<T>]): void;
Old example

I understand this this particular example looks complex. I'm merely using it as a motivating example to showcase what I'm trying to do:

  • limit what kind of values are possible for the initial property (based on the keys of the inferred object)
  • make this property available conditionally - it shouldn't be allowed where the type property of the "current" object is 'paralel'

A way simpler demo of the inference algorithm shortcomings for this kind of things has been mentioned above (playground link)

type IsNever<T> = [T] extends [never] ? true : false;

type StateType = "parallel" | "final" | "compound" | "atomic";

type StateDefinition = {
  type?: StateType;
  states?: Record<string, StateDefinition>;
};

type State<T extends StateDefinition> = (T["type"] extends
  | "parallel"
  | undefined
  ? {}
  : IsNever<keyof T["states"]> extends false
  ? { initial: keyof T["states"] }
  : {}) & {
  type?: T["type"];
  states?: {
    [K in keyof T["states"]]: State<T["states"][K] & {}> & {
      on?: Record<string, keyof T["states"]>;
    };
  };
};

declare function createMachine<T extends StateDefinition>(config: State<T>): T;

createMachine({
  // initial property should be available if there are any nested states and if the `type` of this object is not `'parallel'`
  initial: "a", 
  states: {
    a: {},
  },
});

πŸ’» Use Cases

Schema-like APIs could leverage this a ton:

  • we could use is at XState to type our state machines
  • libraries related to JSON schema could use this
  • I bet that TanStack libraries could think of neat ways to leverage this
  • I'm pretty sure that Redux Toolkit could use this instead of "validating things through intersections"

Implementation

I'm willing to work on the implementation but I could use help with figuring out the exact constraints of the algorithm.

I've created locally a promising spike by gathering potential properties on the inference info when the objectType has available index info in this inference round (here) and creating a type out of those when there is no other candidate for it here

@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript Experimentation Needed Someone needs to try this out to see what happens labels Dec 1, 2022
@RyanCavanaugh
Copy link
Member

I understand this this particular example looks complex

A less complex example for novices such as myself would be appreciated, lol 😰. Something that would make sense in a blog post, for example, tends to be pretty compelling.

@Andarist
Copy link
Contributor Author

Andarist commented Dec 1, 2022

How about this one:

type StateType = "parallel" | "final" | "compound" | "atomic";
type StateSchema = Record<string, { type: StateType }>;

declare function createMachine<T extends StateSchema>(
  obj: {
    [K in keyof T]: {
      type: T[K]["type"];
    } & (T[K]["type"] extends "final"
      ? {}
      : {
          on: Record<string, keyof T>;
        });
  }
): T;

In here, I restrict the presence of on property within the "current" state - it shouldn't be available on a state of type 'final'. I'd like for this reverse mapped type to be inferred as the same type that I provide explicitly here

@Andarist
Copy link
Contributor Author

@RyanCavanaugh I believe that this proposal has a lot of potential to simplify types of some popular libraries, like React Query, Redux Toolkit, and more.

The most recent example of problems that people struggle with can be found in this thread. At the moment, they resort to recursive conditional types but this technique fails to infer unannotated arguments within tuple elements - this is something that works just great with reverse mapped types. The problem is though that they need to infer multiple different things per tuple element and that isn't possible right now - but it could be, with this proposal implemented.

@phryneas
Copy link

phryneas commented Jan 2, 2023

@RyanCavanaugh maybe I can provide a "real life" example of where this can be useful - from Redux Toolkit.

At the moment, it kinda works, but our types to enforce this are pretty wonky; there is not much inference, and we already had the case where a TS PR had to be rolled back until we could figure out a fix. Better support from TS would be highly appreciated!
Playground Link

import { createSlice, PayloadAction } from "@reduxjs/toolkit";

interface State {
  foo: string;
}

const slice = createSlice({
  name: "someSlice",
  initialState: {
    foo: "bar",
  } satisfies State as State,
  reducers: {
    // simple notation - this is easy for us
    simpleReducer(state, action: PayloadAction<string>) {
      state.foo = action.payload;
    },
    // notation that is "split" into a `prepare` function that creates the `action` that will be passed into `reducer`
    reducerWithPrepareNotation: {
      reducer(state, action: PayloadAction<string>) {
        state.foo = action.payload;
      },
      prepare(char: string, repeats: number) {
        return { payload: char.repeat(repeats) };
      },
    },
    // another version with a different action type - but still matching between `reducer` and `prepare`
    reducerWithAnotherPrepareNotation: {
      reducer(state, action: PayloadAction<number>) {
        state.foo = state.foo.slice(0, action.payload);
      },
      prepare(char: string, repeats: number) {
        return { payload: repeats * char.length };
      },
    },
    /* uncomment to see the error. This is a "wrong user code" that we want to protect against.
    invalidReducerWithPrepareNotation: {
      reducer(state, action: PayloadAction<string>) {
        state.foo = action.payload
      },
      // @ts-expect-error we want this to error, because it returns { payload: number }, while the `reducer` above requires { payload: string } as second argument
      prepare(char: string, repeats: number) {
        return { payload: repeats * char.length }
      }
    },
    */
  },
});

{
  const _expectType: (payload: string) => PayloadAction<string> =
    slice.actions.simpleReducer;
}
{
  const _expectType: (char: string, repeats: number) => PayloadAction<string> =
    slice.actions.reducerWithPrepareNotation;
}
{
  const _expectType: (char: string, repeats: number) => PayloadAction<number> =
    slice.actions.reducerWithAnotherPrepareNotation;
}

Our problem here is to get consistency within that reducerWithPrepareNotation definition while also being consistent within reducerWithAnotherPrepareNotation, but having different types between both of them.

@Andarist
Copy link
Contributor Author

Andarist commented Jan 9, 2023

I was experimenting with #52062 and RTK types. While this PR doesn't implement this feature request here - it already allows me to do quite a lot for some libraries.

I managed to implement most of the requirements mentioned by @phryneas. I could have made a mistake here or there - but my experiment probably could have been refined with someone more intimate with RTK.

PoC RTK types with #52062
type AnyFunction = (...args: any) => any;

type PayloadAction<
  P = void,
  T extends string = string,
  M = never,
  E = never
> = {
  payload: P;
  type: T;
} & ([M] extends [never]
  ? {}
  : {
      meta: M;
    }) &
  ([E] extends [never]
    ? {}
    : {
        error: E;
      });

type PrepareMap<TPrepareMap> = {
  [K in keyof TPrepareMap]: {
    prepare?: (...args: never) => TPrepareMap[K];
  };
};

type ReducerMap<TState, TReducerMap, TPrepareMap> = {
  [K in keyof TReducerMap]:
    | ((state: TState, action: never) => void)
    | {
        reducer: (
          state: TState,
          action: TPrepareMap[K & keyof TPrepareMap] & { type: K }
        ) => void;
      };
};

export declare function createSlice<
  TState,
  TPrepareMap,
  TReducerMap,
  TFullReducers
>(arg: {
  name: string;
  initialState: TState;
  reducers: PrepareMap<TPrepareMap> &
    ReducerMap<TState, TReducerMap, TPrepareMap> & {
      [K in keyof TFullReducers]: TFullReducers[K];
    };
}): {
  actions: {
    [K in keyof TFullReducers]: TFullReducers[K] extends {
      reducer: infer R extends AnyFunction;
      prepare: infer P extends AnyFunction;
    }
      ? (...args: Parameters<P>) => Parameters<R>[1]
      : TFullReducers[K] extends infer R extends AnyFunction
      ? Parameters<R>[1] extends PayloadAction<infer P>
        ? (arg: P) => PayloadAction<P>
        : never
      : never;
  };
};

interface State {
  foo: string;
}

const slice = createSlice({
  name: "someSlice",
  initialState: {
    foo: "bar",
  } satisfies State as State,
  reducers: {
    simpleReducer: (state, action: PayloadAction<string>) => {
      state.foo = action.payload;
    },
    reducerWithPrepareNotation: {
      reducer: (state, action) => {
        state.foo = action.payload;
      },
      prepare: (char: string, repeats: number) => {
        return { payload: char.repeat(repeats) };
      },
    },
    reducerWithAnotherPrepareNotation: {
      reducer: (state, action: PayloadAction<number>) => {
        state.foo = state.foo.slice(0, action.payload);
      },
      prepare: (char: string, repeats: number) => {
        return { payload: repeats * char.length };
      },
    },
    // // uncomment to see the error. This is a "wrong user code" that we want to protect against.
    // invalidReducerWithPrepareNotation: {
    //   reducer(state, action: PayloadAction<string>) {
    //     state.foo = action.payload
    //   },
    //   prepare(char: string, repeats: number) {
    //     return { payload: repeats * char.length }
    //   }
    // },
  },
});

{
  const _expectType: (payload: string) => PayloadAction<string> =
    slice.actions.simpleReducer;
}
{
  const _expectType: (char: string, repeats: number) => PayloadAction<string> =
    slice.actions.reducerWithPrepareNotation;
}
{
  const _expectType: (char: string, repeats: number) => PayloadAction<number> =
    slice.actions.reducerWithAnotherPrepareNotation;
}

What I've learned in the process is that this feature request here would make it easier to write such types (since we would be able to "merge" TPrepareMap with TReducerMap) but it wouldn't be able to replace their "validation" logic without #52062.

The main problem is that TS often doesn't infer to type params within intersected types - which I think makes sense in most cases. So to create the return type we actually need to infer separately to TFullReducers and that can only be done using intersections and the logic from #52062.

@RyanCavanaugh RyanCavanaugh added this to the Backlog milestone Feb 28, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Experimentation Needed Someone needs to try this out to see what happens Suggestion An idea for TypeScript
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants