Reputation: 1376
I'm trying to describe strong typesafe constraints around a JSON mapping function. This function takes an object as first parameter and returns a mapped representation of this object using mapping functions passed as second parameter.
As a consumer point of view, something like this contract :
let mappedResult = mapJson(
// Standard plain object literal coming, most of the time from serverside, generally described by an interface
// Let's call this type SRC
{ date: "2018-10-04T00:00:00+0200", date2: 1538604000000, aString: "Hello%20World", idempotentValue: "foo" },
// Applying some mapping aimed at converting input values above and change their type representation
// Rules are :
// - Keys should be a subset of SRC's keys, except for "new" computed keys
// - Values should be function taking SRC[key] and returning a new type NEW_TYPE[key] we want to capture in
// order to reference it in mapJson()'s result type
// Let's call this type TARGET_MAPPINGS
{ date: Date.parse, date2: (ts: number) => new Date(ts), aString: unescape, computed: (_, obj) => `${obj?`${obj.aString}__${obj.idempotentValue}`:''}` }
);
// Result type (NEW_TYPE) should be a map with its keys being the union of SRC keys and TARGET_MAPPINGS keys with following rules :
// - If key exists only in SRC, then NEW_TYPE[key] = SRC[key}
// - Otherwise (key existing in TARGET_MAPPINGS), then NEW_TYPE[key] = ResultType<TARGET_MAPPINGS[key]>
// In this example, expecting
// mappedResult = { date: Date.parse("2018-10-04T00:00:00+0200"), date2: new Date(1538604000000), aString: unescape("Hello%20World"), idempotentValue: "foo", computed: "Hello%20World__foo" }
// .. meaning that expected type would be { date: number, date2: Date, aString: string, idempotentValue: string, computed: string }
With some help (see this SO question) I managed to get it mostly working with following types :
type ExtractField<ATTR, T, FALLBACK> = ATTR extends keyof T ? T[ATTR] : FALLBACK;
type FunctionMap<SRC> = {
[ATTR in string]: (value: ExtractField<ATTR, SRC, never>, obj?: SRC) => any
}
type MappedReturnType<SRC, TARGET_MAPPINGS extends FunctionMap<SRC>> = {
[ATTR in (keyof TARGET_MAPPINGS | keyof SRC)]:
ATTR extends keyof TARGET_MAPPINGS ? ReturnType<Extract<TARGET_MAPPINGS[ATTR], Function>> : ExtractField<ATTR, SRC, never>
}
export function mapJson<
SRC extends object,
TARGET_MAPPINGS extends FunctionMap<SRC>
>(src: SRC, mappings: TARGET_MAPPINGS): MappedReturnType<SRC, TARGET_MAPPINGS> {
// impl .. not the point of the question
}
Everything looks good except "computed" property case which is resolved to type any
(instead of string
)
let mappedResult = mapJson(
{ date: "2018-10-04T00:00:00+0200", date2: 1538604000000, aString: "Hello%20World", idempotentValue: "foo" },
{ date: Date.parse, date2: (ts: number) => new Date(ts), aString: unescape, computed: (_, obj) => `${obj?`${obj.aString}__${obj.idempotentValue}`:''}` }
);
let v1 = mappedResult.date; // number, expected
let v2 = mappedResult.date2; // Date, expected
let v3 = mappedResult.aString; // string, expected
let v4 = mappedResult.idempotentValue; // string, expected
let v5 = mappedResult.computed; // any, NOT expected (expectation was string here !)
I guess this is related to infer
type resolution, but I don't really see why this works for properties both existing in SRC
& TARGET_MAPPINGS
(date
, date2
& string
) and not on properties existing only in TARGET_MAPPINGS
.
May I have spotted a bug ?
Thanks in advance for your help.
Upvotes: 4
Views: 427
Reputation: 30879
FunctionMap
is not doing at all what you intend. TypeScript doesn't support mapped types with string
as the constraint type and different property types depending on the actual string. If you try to declare such a mapped type, the compiler turns it into a type with a string index signature and just replaces all occurrences of the key variable with string
, i.e.:
type FunctionMap<SRC> = {
[ATTR: string]: (value: ExtractField<string, SRC, never>, obj?: SRC) => any
}
Now, since string
doesn't extend keyof SRC
for the SRC
types you are using, the type of the value
parameter is always never
. Then, when the type of the computed
property of MappedReturnType<SRC, TARGET_MAPPINGS>
is evaluated, evaluation of ReturnType<Extract<TARGET_MAPPINGS[ATTR], Function>>
fails because Extract<TARGET_MAPPINGS[ATTR], Function>
is (value: never: obj?: SRC) => any
, and the never
isn't compatible with the constraint (...args: any[]) => any
of ReturnType
. The compiler recovers from the failure by changing the type to Edit: On second thought, I think this has nothing to do with issue 25673 and the conditional type in any
; it's a bug that the compiler doesn't report an error. Issue 25673 has the same root cause, so we should probably add this case on to that issue rather than filing a new one.ReturnType
is just simplifying to its else case, any
.
I tried to find an alternative solution to your original problem, and I was unable to get TypeScript to infer the proper types without breaking the map into two maps, one for original properties and one for computed properties. Even apart from the problem described above, using never
as the type of the value
parameter for a computed property is unsound because you do pass a value for that parameter, presumably undefined
. With a single map, I couldn't find a way to get TypeScript to infer that value
is SRC[ATTR]
for original properties and undefined
for all other property names. Here is what I came up with:
type FieldMap<SRC> = {
[ATTR in keyof SRC]?: (value: SRC[ATTR], obj: SRC) => any
};
type ComputedMap<SRC> = {
[ATTR in keyof SRC]?: never
} & {
[ATTR: string]: (value: undefined, obj: SRC) => any
};
type MappedReturnType<SRC, FM extends FieldMap<SRC>, CM extends ComputedMap<SRC>> = {
[ATTR in keyof CM]: ReturnType<CM[ATTR]>
} & {
[ATTR in keyof SRC]: ATTR extends keyof FM
? FM[ATTR] extends (value: SRC[ATTR], obj: SRC) => infer R ? R : SRC[ATTR]
: SRC[ATTR]
}
export function mapJson<
SRC extends object, FM extends FieldMap<SRC>, CM extends ComputedMap<SRC>
>(src: SRC, fieldMap: FM, computedMap: CM): MappedReturnType<SRC, FM, CM> {
// impl .. not the point of the question
return undefined!;
}
let mappedResult = mapJson(
{ date: "2018-10-04T00:00:00+0200", date2: 1538604000000, aString: "Hello%20World", idempotentValue: "foo" },
// Without `: number`, `ts` is inferred as any:
// probably https://github.com/Microsoft/TypeScript/issues/24694.
{ date: Date.parse, date2: (ts: number) => new Date(ts), aString: unescape},
{computed: (_, obj) => `${obj?`${obj.aString}__${obj.idempotentValue}`:''}` }
);
let v1 = mappedResult.date; // number, expected
let v2 = mappedResult.date2; // Date, expected
let v3 = mappedResult.aString; // string, expected
let v4 = mappedResult.idempotentValue; // string, expected
let v5 = mappedResult.computed; // string, expected
Note that the contextual typing of the parameters of field mapping functions still isn't working. I believe that's due to this TypeScript issue and I suggest you vote for the issue.
Upvotes: 2