Joseph Sikorski
Joseph Sikorski

Reputation: 715

How to create mapped types derived from other mapped types with assignable keys

Minor Edit: This was happening for me on TS 3.0.1

I've been running into issues using Typescript to nail down a config shape for use with a React component enhancer. In essence, I want to specify a map-object whose properties are used by the enhancer to create injected props for the enhanced component.

Where I've been running into issues seems to be trying to create mapped types derived from other mapped types. In total, for the base enhancer, I have these derivations:

And for a precomposed enhancer created for a common use case:

The issues I tend to run into here are:

My questions then are:

Here is a small example that seems to elicit the primary errors I'm running into:

EDIT: Link to the TS playground with this in it (it just crashes the compiler on my computer/browser, though)

// NOTE: Crashes the playground in chrome on my computer:
//   RangeError: Maximum call stack size exceeded
// Also probably crashes a tsserver process/request/thing because
// vscode stops updating the error squigglies after a bit.

// Convenience.
type PropKeyOf<T> = Extract<keyof T, string>;

// A "related" type that I want to be related to a "base" type.
// Particularly, I want to be able to derive the "base" type.
// "related" and "base" are used here because these are config types
// for the interface of a related enhancer utility and
// a base enhancer utility respectively.
// They are otherwise unrelated.

type RelatedMap<T> = {
  [K in PropKeyOf<T>]: RelatedMapPropType<T[K]>;
};

type RelatedMapPropType<T> = T extends RelatedMapProp<infer V> ? RelatedMapProp<V> : never;

type RelatedMapProp<V> = { foo: V, init(): V };

// A "base" type that I want to use for a "base" interface.

type BaseMap<T> = {
  [K in PropKeyOf<T>]: BaseMapPropType<T[K]>;
};

type BaseMapPropType<T> = T extends BaseMapProp<infer V> ? BaseMapProp<V> : never;

type BaseMapProp<V> = { baz: V, init(): V };

// Make the conversion type
type BaseMapOfRelatedMap<TRel extends RelatedMap<TRel>> = {
  [K in PropKeyOf<TRel>]: BasePropOfRelatedMapProp<TRel[K]>;
}

type BasePropOfRelatedMapProp<TRelProp> = TRelProp extends RelatedMapProp<infer V> ? BaseMapProp<V> : never;

function isOwnProp<O extends {}>(o: O, pn: string): pn is PropKeyOf<O> {
  return !!o && (typeof o === 'object') && Object.prototype.hasOwnProperty.call(o, pn);
}

function createBaseMapOfRelatedMap<
  TRel extends RelatedMap<TRel>,
  // Error:
  // - [ts] Type 'BaseMapOfRelatedMap<TRel>' does not satisfy the constraint 'BaseMap<TBase>'.
  //   - Type 'Extract<keyof TBase, string>' is not assignable to
  //     type 'Extract<keyof TRel, string>'.
  TBase extends BaseMap<TBase> = BaseMapOfRelatedMap<TRel>
>(foo: TRel): TBase {
  const baz = {} as TBase;

  for (const propName in foo) if (isOwnProp(foo, propName)) {
    // Errors:
    // - [ts] Type 'Extract<keyof TRel, string>' cannot be used
    //   to index type 'TBase'.
    // - [ts] Property 'foo' does not exist
    //   on type 'TRel[Extract<keyof TRel, string>]'.
    baz[propName] = { baz: foo[propName].foo, init: foo[propName].init };
  }

  return baz;
}

Edit 1

Thanks for the help, Matt!

NOTE: fixed the example names up.

On TBase

As for the specific error that 'Extract<keyof TRel, string>' cannot be used to index type 'TBase', this is because TRel and TBase are independent type parameters; TBase has a default, but it can be overridden by a caller. So there's nothing to prevent TRel from having properties that TBase does not.

That makes sense, good point, I wasn't really thinking of that at the time, kinda had my head buried deep in one way of thinking. Guess that means I can't use type params to shorten that unless I want to add more extends ... constraints.

So, like this:

// added to try to typecheck created prop.
function createBasePropOfRelatedMapProp<
  TRelProp extends RelatedMapProp<TRelProp>,
>(fooProp: TRelProp): BasePropOfRelatedMapProp<TRelProp> {
  return { baz: fooProp.foo, init: fooProp.init };
}

function createBaseMapOfRelatedMap<
  TRel extends RelatedMap<TRel>,
>(foo: TRel): BaseMapOfRelatedMap<TRel> {
  const baz = {} as BaseMapOfRelatedMap<TRel>;

  for (const propName in foo) if (isOwnProp(foo, propName)) {
    baz[propName] = createBasePropOfRelatedMapProp(foo[propName]);
  }

  return baz;
}

function logBaseMap<TBase extends BaseMap<TBase>>(base: TBase): void {
  for (const propName in base) if (isOwnProp(base, propName)) {
    console.log(propName, '=>', base[propName]);
  }
}

Unfortunately, this is crashing the tsserver request again:

Err 551   [15:35:42.708] Exception on executing command delayed processing of request 12:

    Maximum call stack size exceeded

    RangeError: Maximum call stack size exceeded
    at getSimplifiedIndexedAccessType (/.../client/node_modules/typescript/lib/tsserver.js:37544:48)
    at getSimplifiedType (/.../client/node_modules/typescript/lib/tsserver.js:37540:63)
    at getConstraintOfDistributiveConditionalType (/.../client/node_modules/typescript/lib/tsserver.js:35523:54)
    at getConstraintOfConditionalType (/.../client/node_modules/typescript/lib/tsserver.js:35535:20)
    at getConstraintOfType (/.../client/node_modules/typescript/lib/tsserver.js:35496:62)
    at getConstraintOfDistributiveConditionalType (/.../client/node_modules/typescript/lib/tsserver.js:35523:34)
    at getConstraintOfConditionalType (/.../client/node_modules/typescript/lib/tsserver.js:35535:20)
    at getConstraintOfType (/.../client/node_modules/typescript/lib/tsserver.js:35496:62)
    (... repeat ad nauseum)

Alas.

Original Context

I tried to simplify the example to the bare minimum to illustrate the errors, but this of course lost the original context, even if I stated the context in the description of the problem.

The original code essentially works something like this:

const config = {
  // sometimes I only need just the request itself.
  foo: (ownProps: ComponentOwnProps) => () => apiFetch(`/api/foos/${ownProps.fooId}`),

  // sometimes I need more control.
  bar: {
    request: (ownProps: ComponentOwnProps) => (barId: string) => apiFetch(`/api/foos/${ownProps.fooId}/bars/${barId}`),
    reduce: (
      prevPropValue: { [k: string]: AsyncData<APIResponse> },
      nextResValue: AsyncData<APIResponse>,
      ownProps: ComponentOwnProps,
      [barId]: [string]
    ) => ({
      ...prevPropValue,
      [barId]: nextResValue,
    }),
    initial: () => ({} as { [k: string]: AsyncData<APIResponse> })
  },
};

const enhanceComponent = withAsyncData(config);

I wanted to then use the mapped type constraints to ensure that all props on the config shared the same OwnProps type and that each prop itself was internally consistent with regards to the types used therein, mostly noticeable in bar, where for instance reduce should return the same type as its prevPropValue argument, and that initial should also return that same type; but also that the last array argument to reduce is a tuple of the args types of the function returned by request.

As part of this, I needed to then generate a type for the props that get injected by this config:

I then wanted a variation on the above config for use with a precomposition of withAsyncData with React-Redux's connect, which ended up looking like this:

const config = {
  foo: (dispatch: AppDispatch) => (ownProps: ComponentOwnProps) => () => apiFetch(`/api/foos/${ownProps.fooId}`),
  bar: {
    request: (dispatch: AppDispatch) => (ownProps: ComponentOwnProps) => (barId: string) => apiFetch(`/api/foos/${ownProps.fooId}/bars/${barId}`),
    reduce: (
      prevPropValue: { [k: string]: AsyncData<APIResponse> },
      nextResValue: AsyncData<APIResponse>,
      ownProps: ComponentOwnProps,
      [barId]: [string]
    ) => ({
      ...prevPropValue,
      [barId]: nextResValue,
    }),
    initial: () => ({} as { [k: string]: AsyncData<APIResponse> })
  },
};

const enhanceComponent = withConnectedAsyncData(config);

The precomposition is (essentially) just config => compose(connect(null, createMapDispatchToProps(config)), withAsyncData(createAsyncDataConfig(config))). But of course I need to create a base config type derived from that (slightly) extended config type using createAsyncDataConfig().

Upvotes: 0

Views: 1531

Answers (1)

Matt McCutchen
Matt McCutchen

Reputation: 30999

I don't understand the end goal; an example of input and output would be really helpful. As for the specific error that 'Extract<keyof TRel, string>' cannot be used to index type 'TBase', this is because TRel and TBase are independent type parameters; TBase has a default, but it can be overridden by a caller. So there's nothing to prevent TRel from having properties that TBase does not. For example, a caller could do:

createBazOfRelatedMap<{x: number}, {}>(...);

And the code would try to index baz with the property x, which it doesn't have.

Round 2

This is working for me as a solution to the original problem and hasn't crashed the compiler so far:

// DUMMY DECLARATIONS
interface AsyncData<T> {
  asyncDataMarker: T;
}
interface APIResponse {
  apiResponseMarker: undefined;
}
declare function apiFetch(url: string): AsyncData<APIResponse>;
interface ComponentOwnProps {
  fooId: string;
}
interface AppDispatch {
  appDispatchMarker: undefined;
}

// FIRST VERSION

type SimpleConfigEntry<OwnProps, Response> = (ownProps: OwnProps) => () => Response;
type ComplexConfigEntry<OwnProps, RequestArgs extends unknown[], Response, PropValue> = {
  request: (ownProps: OwnProps) => (...args: RequestArgs) => Response,
  reduce: (
    prevPropValue: PropValue,
    nextResValue: Response,
    ownProps: OwnProps,
    args: RequestArgs
  ) => PropValue,
  initial: () => PropValue
};

type CheckConfigEntry<OwnProps, T> = 
  T extends ComplexConfigEntry<OwnProps, infer RequestArgs, infer Response, infer PropValue>
    ? (ComplexConfigEntry<OwnProps, RequestArgs, Response, PropValue> extends T ? T : never)
    : T extends SimpleConfigEntry<OwnProps, infer Response>
      ? (SimpleConfigEntry<OwnProps, Response> extends T ? T : never)
      : never;

type ConfigEntryCommonInferrer<OwnProps, Response> =
  ((ownProps: OwnProps) => () => Response) | {request: (ownProps: OwnProps) => (...args: any[]) => Response};

declare function withAsyncData
  <OwnProps, C extends {[K in keyof C]: CheckConfigEntry<OwnProps, C[K]>}>
  (config: C & {[k: string]: ConfigEntryCommonInferrer<OwnProps, any>}): /*TODO*/ unknown;

type InjectedProps<C> = {
  getAsyncData: {[K in keyof C]: C[K] extends ConfigEntryCommonInferrer<any, infer Response> ? Promise<Response> : unknown},
  asyncData: {[K in keyof C]: C[K] extends ConfigEntryCommonInferrer<any, infer Response> ? Response : unknown}
}

// Example

const config = {
  // sometimes I only need just the request itself.
  foo: (ownProps: ComponentOwnProps) => () => apiFetch(`/api/foos/${ownProps.fooId}`),

  // sometimes I need more control.
  bar: {
    request: (ownProps: ComponentOwnProps) => (barId: string) => apiFetch(`/api/foos/${ownProps.fooId}/bars/${barId}`),
    reduce: (
      prevPropValue: { [k: string]: AsyncData<APIResponse> },
      nextResValue: AsyncData<APIResponse>,
      ownProps: ComponentOwnProps,
      [barId]: [string]
    ) => ({
      ...prevPropValue,
      [barId]: nextResValue,
    }),
    initial: () => ({} as { [k: string]: AsyncData<APIResponse> }),
  },
};

const enhanceComponent = withAsyncData(config);
type ExampleInjectedProps = InjectedProps<typeof config>;

// SECOND VERSION

type SimpleConfigEntry2<Dispatch, OwnProps, Response> = (dispatch: Dispatch) => (ownProps: OwnProps) => () => Response;
type ComplexConfigEntry2<Dispatch, OwnProps, RequestArgs extends unknown[], Response, PropValue> = {
  request: (dispatch: Dispatch) => (ownProps: OwnProps) => (...args: RequestArgs) => Response,
  reduce: (
    prevPropValue: PropValue,
    nextResValue: Response,
    ownProps: OwnProps,
    args: RequestArgs
  ) => PropValue,
  initial: () => PropValue
};

type CheckConfigEntry2<Dispatch, OwnProps, T> = 
  T extends ComplexConfigEntry2<Dispatch, OwnProps, infer RequestArgs, infer Response, infer PropValue>
    ? (ComplexConfigEntry2<Dispatch, OwnProps, RequestArgs, Response, PropValue> extends T ? T : never)
    : T extends SimpleConfigEntry2<Dispatch, OwnProps, infer Response>
      ? (SimpleConfigEntry2<Dispatch, OwnProps, Response> extends T ? T : never)
      : never;

type ConfigEntryCommonInferrer2<Dispatch, OwnProps, Response> =
  ((dispatch: Dispatch) => (ownProps: OwnProps) => () => Response) |
  {request: (dispatch: Dispatch) => (ownProps: OwnProps) => (...args: any[]) => Response};

declare function withConnectedAsyncData
  <Dispatch, OwnProps, C extends {[K in keyof C]: CheckConfigEntry2<Dispatch, OwnProps, C[K]>}>
  (config: C & {[k: string]: ConfigEntryCommonInferrer2<Dispatch, OwnProps, any>}): /*TODO*/ unknown;

// Example

const config2 = {
  foo: (dispatch: AppDispatch) => (ownProps: ComponentOwnProps) => () => apiFetch(`/api/foos/${ownProps.fooId}`),
  bar: {
    request: (dispatch: AppDispatch) => (ownProps: ComponentOwnProps) => (barId: string) => apiFetch(`/api/foos/${ownProps.fooId}/bars/${barId}`),
    reduce: (
      prevPropValue: { [k: string]: AsyncData<APIResponse> },
      nextResValue: AsyncData<APIResponse>,
      ownProps: ComponentOwnProps,
      [barId]: [string]
    ) => ({
      ...prevPropValue,
      [barId]: nextResValue,
    }),
    initial: () => ({} as { [k: string]: AsyncData<APIResponse> })
  },
};

const enhanceComponent2 = withConnectedAsyncData(config2);

Upvotes: 1

Related Questions