I'm currently working on implementing robust typesafe constraints for a JSON mapping function. This particular function accepts an object as its first parameter and returns a mapped representation of that object by utilizing mapping functions provided as the second parameter.
From the perspective of a consumer, the contract would look something like this:
let mappedResult = mapJson(
// Standard plain object literal data usually obtained from the server-side, often defined by an interface
// We'll refer to this type as SRC
{ date: "2018-10-04T00:00:00+0200", date2: 1538604000000, aString: "Hello%20World", idempotentValue: "foo" },
// Application of specific mappings aimed at transforming the input values and altering their type representations
// The rules are:
// - Keys should be a subset of SRC's keys, except for any new computed keys
// - Values should be functions that take SRC[key] as input and return a new type NEW_TYPE[key], which we aim to capture in order to reference it in the result type of mapJson()
// Let's label this type as TARGET_MAPPINGS
{ date: Date.parse, date2: (ts: number) => new Date(ts), aString: unescape, computed: (_, obj) => `${obj ? `${obj.aString}__${obj.idempotentValue}` : ''}` }
);
// The resulting type (NEW_TYPE) should be a mapping with keys being the union of SRC keys and TARGET_MAPPINGS keys according to these guidelines:
// - If the key exists solely 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, the expected output is:
// mappedResult = { date: Date.parse("2018-10-04T00:00:00+0200"), date2: new Date(1538604000000), aString: unescape("Hello%20World"), idempotentValue: "foo", computed: "Hello%20World__foo" }
// .. indicating that the anticipated type would be { date: number, date2: Date, aString: string, idempotentValue: string, computed: string }
With some assistance (refer to this SO question), I've made significant progress and have implemented the 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> {
// Implementation details are not the focus of this inquiry
}
Everything seems to be functioning correctly except for the scenario involving the "computed" property, where the resolved type is 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, as expected
let v2 = mappedResult.date2; // Date, as expected
let v3 = mappedResult.aString; // string, as expected
let v4 = mappedResult.idempotentValue; // string, as expected
let v5 = mappedResult.computed; // any, NOT what was expected (expected type was string here!)
I suspect this might be attributed to the type resolution using infer
, but I am unsure why it behaves differently for properties existing in both SRC
and TARGET_MAPPINGS
(date
, date2
, & aString
) compared to properties only present in TARGET_MAPPINGS
.
Could this be potentially a bug?
Thank you in advance for your assistance.