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:
- Given a base config, whose props could be one of two shapes...
- Create a normalized config whose props are the same as the props of the base config, but only of one shape.
- Create a component props type which has...
- Props that are functions, one for each base config prop.
- Props that are values, one for each base config prop.
And for a precomposed enhancer created for a common use case:
- A
mapDispatchToProps
function which had as its return type props derived from a slightly different dispatch-aware version of config. - A derivation of a base config from that dispatch-aware config.
The issues I tend to run into here are:
- Non-assignability of keys:
Type 'Extract<keyof TBase, string>' is not assignable to type 'Extract<keyof TRel, string>'.
- Non-indexability of base type when derived from related type:
Type 'Extract<keyof TRel, string>' cannot be used to index type 'TBase'.
- Basically the same as the first one but at the point of assignment.
- TypeScript language server request or compiler dying with
RangeError: Maximum call stack size exceeded
, probably due to lots of conditional types and mapped types.
My questions then are:
- Is this possible to accomplish in TypeScript?
- If so, there a better way to do it than what I did while both sticking to plain objects and still retaining the per-prop type constraints?
- Why are two sets of keys-unions derived from two mapped types non-assignable when they're both ultimately derived from only one mapped type? Is it because even though they are both derived from one, TS only evaluates the keys-unions assignability by the parameter constraints rather than the specific instantiated types? Or something else I'm missing?
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 becauseTRel
andTBase
are independent type parameters;TBase
has a default, but it can be overridden by a caller. So there's nothing to preventTRel
from having properties thatTBase
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:
props.getAsyncData.foo(): Promise<AsyncData<APIResponse>>
props.getAsyncData.bar(barId: string): Promise<AsyncData<APIResponse>>
props.asyncData.foo: AsyncData<APIResponse>
props.asyncData.bar: AsyncData<APIResponse>
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()
.
BaseMapOfRelatedMap
wrong. I suspect your call stack is exploding because you're attempting to declare a class that extends a generic that uses itself. – Ian MacDonaldtype BaseMapOfRelatedMap<TRel extends RelatedMap<TRel>> = ...
to justtype BaseMapOfRelatedMap<TRel> = ...
andTBase extends BaseMap<TBase> = ...
to justTBase = ...
don't seem to increase the perf any more, though the second does remove the firstType Extract<...> is not assignable to...
error. Doesn't fix the'Extract<keyof TRel, string>' cannot be used to index type 'TBase'
error, though. – Joseph Sikorskimaster
version of TypeScript. I don't see an existing matching bug, although it's conceivable that github.com/Microsoft/TypeScript/issues/22950 may be related. Want to file an issue or shall I? I'll respond separately to the other questions. – Matt McCutchen