Reputation: 436
Let say that I have two types
type A = {
fi_name: string;
l_name: string;
f_name: string;
}
type B = {
firstName: string;
lastName: string;
fullName: string;
}
Is there a way to easily convert an object back and forth between these types?
Extra internet points if the answers also handles nested types and/or can be implemented using an expressjs middleware.
Upvotes: 1
Views: 4436
Reputation: 43
here is js version
function createObjectConverter(mp) {
function equals(a, b) {
return a.length === b.length && a.every((v, i) => v === b[i]);
}
function reverseMap(mapping) {
return Object.entries(mapping).reduce((reversed, [sourceKey, targetKey]) => {
if (typeof targetKey === 'object' && !Array.isArray(targetKey)) {
reversed[sourceKey] = reverseMap(targetKey);
} else if (typeof targetKey === 'object' && Array.isArray(targetKey)) {
reversed[sourceKey] = targetKey;
} else {
reversed[targetKey] = sourceKey;
}
return reversed;
}, {});
}
return function convertObject(object, map = mp) {
const isReversed = !equals(Object.keys(object), Object.keys(map));
const m = isReversed ? reverseMap(map) : map;
return Object.entries(m).reduce((newObject, [key, value]) => {
if (typeof object[key] === 'object' && !Array.isArray(object[key])) {
newObject[key] = convertObject(object[key], value);
} else if (typeof object[key] === 'object' && Array.isArray(object[key])) {
newObject[key] = object[key];
} else {
newObject[value] = object[key];
}
return newObject;
}, {});
};
}
const convert = createObjectConverter({
name: 'full_name',
contact: {
email: 'user_email',
phone: 'user_phone',
},
azat: 'is_azat',
array: [],
});
const originalObject = {
name: 'John',
contact: {
email: '[email protected]',
phone: '555-123-4567',
},
azat: false,
array: ['1', 3],
};
const convertedObject = convert(originalObject);
console.log(convertedObject);//= { array: [ '1', 3 ], contact: { user_email: '[email protected]', user_phone: '555-123-4567' }, full_name: 'John',is_azat: false }
const reverseConvertedObject = convert(convertedObject);
console.log(reverseConvertedObject);//= { array: [ '1', 3 ], azat: false, contact: { email: '[email protected]', phone: '555-123-4567' },name: 'John' }
Upvotes: 1
Reputation: 328292
I'm not sure any internet points are worth the effort of solving the issue for arbitrarily nested properties. In order to do this programmatically, you need your function to accept a "mapping" schema from one type to the other. For example, given your type A
and type B
, you might do this:
const aToB = makeMapper<A, B>()({
f_name: "fullName",
fi_name: "firstName",
l_name: "lastName"
} as const);
Here you can see that the hypothetical makeMapper
function is generic and lets you specify the desired input type (A
) and the desired output type (B
), and then the output of that accepts the mapping schema, where each property name is mapped to another property name. (This is a curried function because TypeScript lacks partial specification of type parameters; you need type parameters for input and output, as well as one for the mapping. See this answer for more info.) (Also, you need that const
assertion to make sure the compiler is aware of the string literal values like "fullName"
and does not widen them to string
).
If you do something wrong in your mapping, you'd expect some kind of error message:
const badMapper = makeMapper<A, B>()({
f_name: "fullName",
fi_name: "firstName",
l_name: "lostName",
} as const); // error!
// 'Invalid<{ lastName: "missing or misspelled property key"; }>'
Here I wrote lostName
instead of lastName
so I'd want an error on the above type telling me that I got the lastName
property wrong in some way.
For object properties, you need the mapping schema to accept not just a renamed key but also a mapper for the object type. For example:
interface AccountA {
user: A
}
interface AccountB {
user: B
}
const accountAToAccountB = makeMapper<AccountA, AccountB>()({
user: ["user", aToB]
} as const);
Here the AccountA
to AccountB
mapper keeps the property named user
but changes the type from A
to B
with the aToB
mapper we created earlier.
Then, to actually use the resultant mapper forwards and backwards would look like this:
const a: AccountA = {
user: {
fi_name: "Harry",
l_name: "Potter",
f_name: "Harry Potter"
}
}
const b: AccountB = accountAToAccountB.map(a);
console.log(b);
/* {
"user": {
"firstName": "Harry",
"lastName": "Potter",
"fullName": "Harry Potter"
}
} */
const aAgain: AccountA = accountAToAccountB.reverseMap(b);
console.log(aAgain);
/* {
"user": {
"fi_name": "Harry",
"l_name": "Potter",
"f_name": "Harry Potter"
}
} */
Right?
Well you can do this in TypeScript, but the typings are pretty ugly and involved, and the implementation needs lots of type assertions. Here's how one might do it:
class Mapper<T extends object, M extends ObjectMapping<T>> {
constructor(public mapping: M) { }
map(obj: T): Mapped<T, M> {
return Object.fromEntries(Object.entries(obj).map(([k, v]) => {
if (!(k in this.mapping)) return [k, v];
const m = this.mapping[k as keyof T];
if (Array.isArray(m)) return [m[0], m[1] ? m[1].map(v) : v];
return [m, v];
})) as any;
}
reverseMap(obj: Mapped<T, M>): T {
const revMapping: Record<string, any> = Object.fromEntries(Object.entries(this.mapping).map(([k, m]) => {
if (Array.isArray(m)) return [m[0], [k, m[1]]];
return [m, k];
}));
return Object.fromEntries(Object.entries(obj).map(([k, v]) => {
if (!(k in revMapping)) return [k, v];
const m = revMapping[k];
if (Array.isArray(m)) return [m[0], m[1] ? m[1].reverseMap(v) : v];
return [m, v];
})) as any;
}
}
type ObjectMapping<T extends object> = {
[K in keyof T]: T[K] extends object ? readonly [PropertyKey, Mapper<T[K], any>?] : PropertyKey
}
type Mapped<T extends object, M extends ObjectMapping<T>> = {
[K in keyof T as GetKey<M[K]>]: GetVal<M[K], T[K]>
} extends infer O ? { [K in keyof O]: O[K] } : never;
type GetKey<P> = P extends PropertyKey ? P : P extends readonly [PropertyKey, any?] ? P[0] : never;
type GetVal<P, V> = P extends PropertyKey ? V :
P extends readonly [PropertyKey, Mapper<any, any>] ? V extends object ? Mapped<V, P[1]['mapping']> : V : V;
type Invalid<Err> = {
errMsg: Err
}
function makeMapper<T extends object, U extends object>() {
return <M extends ObjectMapping<T>>(mapping: (Mapped<T, M> extends U ? M : M & Invalid<
{ [K in keyof U as K extends keyof Mapped<T, M> ? Mapped<T, M>[K] extends U[K] ? never : K : K]:
K extends keyof Mapped<T, M> ? Mapped<T, M>[K] extends U[K] ? never :
["wrong property type", U[K], "vs", Mapped<T, M>[K]] : "missing or misspelled property key" }
>)) => new Mapper<T, M>(mapping as M)
}
Should I bother to explain how each piece of that works? The typings are basically a recursive use of TS4.1's key remapping in mapped types. The implementation is also recursively walking down through the mapping
schema and applying the mappings to the input object's entries.
Getting the compiler to produce useful error messages in the face of such complex typings is not straightforward since there are currently no "throw/invalid" types in TypeScript (see microsoft/TypeScript#23689) so I had to use a workaround.
There are probably all kinds of edge cases there, when it comes to object types whose properties are unions of primitives and other object types, or when property names conflict, etc. Something like this would need lots of testing in both the type system and at runtime before you even consider using it in any sort of production environment.
So you might consider the scope of your actual use case and whether you really want some all-purpose remapper, or if a more hardcoded approach would be a better fit. For a pair of types like your A
and B
, the above implementation is probably overkill even if there are no edge cases. For your actual use case? Only you can say.
Upvotes: 5