Skip to content

Commit 40f4b0d

Browse files
feat(util): transformObj util
1 parent e3dbfb8 commit 40f4b0d

File tree

3 files changed

+185
-0
lines changed

3 files changed

+185
-0
lines changed

Diff for: packages/util/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ export * from './opaqueTypes';
1616
export * from './environment';
1717
export * from './patchObject';
1818
export * from './isPromise';
19+
export * from './transformer';
1920
export { PromiseOrValue, resolveObjectValues } from './util';

Diff for: packages/util/src/transformer.ts

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import isUndefined from 'lodash/isUndefined';
2+
import merge from 'lodash/merge';
3+
4+
export interface Transform<From, To, Context = {}> {
5+
(from: From, context?: Context): To extends object
6+
? {
7+
[k in keyof Required<To>]: To[k];
8+
}
9+
: To;
10+
}
11+
12+
export type Transformer<From, To, Context = {}> = {
13+
[k in keyof Required<To>]:
14+
| Transform<From, To[k], Context>
15+
| Transform<From, Promise<To[k]>, Context>
16+
| Transformer<From, To[k], Context>
17+
| Transformer<From, Promise<To[k]>, Context>;
18+
};
19+
20+
const deepOmitBy = (obj: unknown, predicate: (o: unknown) => boolean): unknown => {
21+
if (Array.isArray(obj)) return obj; // Leave arrays alone
22+
23+
if (obj && typeof obj === 'object') {
24+
return (
25+
Object.entries(obj)
26+
.map(([k, v]) => [k, typeof v === 'object' ? deepOmitBy(v, predicate) : v, predicate(v)])
27+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
28+
.reduce((result, [k, v, omit]) => (omit ? result : merge(result, { [k]: v })), {} as any)
29+
);
30+
}
31+
return obj;
32+
};
33+
34+
/**
35+
* The provided object is used to build an object using the provided Transformer.
36+
* It's useful for enforcing complete transformation of one format to another.
37+
*/
38+
export const transformObj = async <From, To, Context = {}>(
39+
from: From,
40+
transformer: Transformer<From, To, Context>,
41+
context?: Context
42+
): Promise<To> => {
43+
const entries = Object.entries(transformer);
44+
const result = Object.create({});
45+
46+
for (const [key, value] of entries) {
47+
result[key] = await (typeof value === 'function'
48+
? (value as Transform<From, unknown, unknown>)(from, context)
49+
: transformObj(from, value as Transformer<From, unknown, Context>, context));
50+
}
51+
52+
return deepOmitBy(result, isUndefined) as To;
53+
};

Diff for: packages/util/test/transformer.test.ts

+131
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { Transformer, transformObj } from '../src';
2+
3+
const stubDns = new Map<string, string>([
4+
['localhost', '0.0.0.0'],
5+
['someAddress', '192.168.0.1'],
6+
['someAddress2', '192.168.0.2']
7+
]);
8+
9+
describe('transformObj transforms the provided object using the provided Transformer object', () => {
10+
test('from larger type into a smaller type', async () => {
11+
type From = { a: string; b: string; c: { d: bigint } };
12+
type To = { a: number; b: number; d: number };
13+
const TestTransformer: Transformer<From, To> = {
14+
a: (from) => Number.parseInt(from.a),
15+
b: (from) => Number.parseInt(from.b),
16+
d: (from) => Number(from.c.d)
17+
};
18+
const transformed = await transformObj({ a: '1', b: '2', c: { d: 4n } }, TestTransformer);
19+
const expected: To = { a: 1, b: 2, d: 4 };
20+
expect(transformed).toEqual(expected);
21+
});
22+
23+
test('from types with array in fields', async () => {
24+
type From = { a: string; b: string[]; c: { d: bigint[] } };
25+
type To = { a: number; b: number[]; d: number };
26+
const TestTransformer: Transformer<From, To> = {
27+
a: (from) => Number.parseInt(from.a),
28+
b: (from) => from.b.map(Number),
29+
d: (from) => Number(from.c.d.reduce((accumulator, currentValue) => accumulator + Number(currentValue), 0))
30+
};
31+
const transformed = await transformObj({ a: '1', b: ['2', '3', '4'], c: { d: [4n, 5n] } }, TestTransformer);
32+
const expected: To = { a: 1, b: [2, 3, 4], d: 9 };
33+
34+
expect(transformed).toEqual(expected);
35+
});
36+
37+
describe('smaller type into a larger type', () => {
38+
type From = { a: number; b: number; d: number };
39+
type To = { a: string; b: string; c: { d: bigint; e?: number } };
40+
const toTransform: From = { a: 1, b: 2, d: 4 };
41+
const expected: To = { a: '1', b: '2', c: { d: 4n } };
42+
43+
test('through top level property mapping', async () => {
44+
const TopLevelTransformer: Transformer<From, To> = {
45+
a: (from) => from.a.toString(),
46+
b: (from) => from.b.toString(),
47+
c: (from) => ({ d: BigInt(from.d), e: void 0 })
48+
};
49+
const transformed = await transformObj(toTransform, TopLevelTransformer);
50+
expect(transformed).toEqual(expected);
51+
expect('e' in transformed.c).toBe(false);
52+
});
53+
54+
test('through nested property mapping', async () => {
55+
const NestedTransformer: Transformer<From, To> = {
56+
a: (from) => from.a.toString(),
57+
b: (from) => from.b.toString(),
58+
c: {
59+
d: (from) => BigInt(from.d),
60+
e: () => void 0
61+
}
62+
};
63+
const transformed = await transformObj(toTransform, NestedTransformer);
64+
expect(transformed).toEqual(expected);
65+
expect('e' in transformed.c).toBe(false);
66+
});
67+
});
68+
69+
test('can use a transformation context', async () => {
70+
// Types
71+
type IpAddress = string;
72+
type Domain = string;
73+
type Context = { dnsResolver: (domain: Domain) => IpAddress };
74+
type From = { a: { domain: Domain; b: { domain: Domain } } };
75+
type To = { a: { ip: IpAddress; b: { ip: IpAddress } } };
76+
77+
const transformationContext = {
78+
dnsResolver: (domain: Domain) => (stubDns.has(domain) ? stubDns.get(domain)! : 'unknown')
79+
};
80+
81+
const testTransformer: Transformer<From, To, Context> = {
82+
a: {
83+
b: {
84+
ip: (from, context) => context!.dnsResolver(from.a.b.domain)
85+
},
86+
ip: (from, context) => context!.dnsResolver(from.a.domain)
87+
}
88+
};
89+
90+
const transformed = await transformObj(
91+
{ a: { b: { domain: 'localhost' }, domain: 'someAddress2' } },
92+
testTransformer,
93+
transformationContext
94+
);
95+
96+
const expected = { a: { b: { ip: '0.0.0.0' }, ip: '192.168.0.2' } };
97+
98+
expect(transformed).toEqual(expected);
99+
});
100+
101+
test('can use a transformation context with async operations', async () => {
102+
// Types
103+
type IpAddress = string;
104+
type Domain = string;
105+
type Context = { dnsResolver: (domain: Domain) => Promise<IpAddress> };
106+
type From = { a: { domain: Domain; b: { domain: Domain } } };
107+
type To = { a: { ip: IpAddress; b: { ip: IpAddress } } };
108+
109+
const transformationContext = {
110+
dnsResolver: async (domain: Domain) => (stubDns.has(domain) ? stubDns.get(domain)! : 'unknown')
111+
};
112+
113+
const testTransformer: Transformer<From, To, Context> = {
114+
a: {
115+
b: {
116+
ip: async (from, context) => await context!.dnsResolver(from.a.b.domain)
117+
},
118+
ip: async (from, context) => await context!.dnsResolver(from.a.domain)
119+
}
120+
};
121+
122+
const transformed = await transformObj(
123+
{ a: { b: { domain: 'localhost' }, domain: 'someAddress' } },
124+
testTransformer,
125+
transformationContext
126+
);
127+
128+
const expected: To = { a: { b: { ip: '0.0.0.0' }, ip: '192.168.0.1' } };
129+
expect(transformed).toEqual(expected);
130+
});
131+
});

0 commit comments

Comments
 (0)