I implemented as an exercise a function which maps over object values in Typescript, and I am truly horrified by my type annotations.
The function:
type map = <F, T>(f:callback<F, T>) => mapping<F, T>;
type callback<F, T> = (value:F, index:number, entries:Entry<F>[]) => T;
type Entry<T=any> = [key:String, value:T];
type mapping<F, T> = <I extends Dic<F>>(obj:I) => Protect<I, Record<keyof I, T>>;
type Dic<T> = Record<string, T>;
type Protect<A, B> = A extends B ? A : B;
const map:map = f => obj =>
Object.fromEntries(
Object.entries(obj)
.map(([key, value], i, arr):Entry =>
[key, f(value, i, arr)]));
Example of usage:
const double = map((x:number) => x * 2);
const shout = map((x:any) => String(x) + '!');
type Abc = {
a: number,
b: number,
c: number,
};
const foo:Abc = {a: 1, b: 2, c: 3}; // Abc
const bar = double(foo); // Abc {a: 2, b: 4, c: 6}
const foobar = shout(foo); // Record<keyof Abc, string> {a: '1!', b: '2!', c: '3!'}
I wanted to preserve as much type information as possible:
bar
is still of typeAbc
foobar
is holding on toAbc
's keys.
I also get type checking when writing the callback.
// @ts-expect-error
const typeError = map((x:number) => x)({a: 1, b: "2"})
I don't know if I over-engineered it but I can't stand to look at this code and this often happens to me in Typescript. I end up extracting as many things as possible in type aliases and I'm not sure it's such a good idea. it clutters the namespace, I have to find good names, type aliases don't expand in VSCode tooltips which is sometimes annoying and I still find the result difficult to look at.
- Could this be simplified?
- Just how do you manage long or complex type definitions so that your code looks good?
Thank you!
2 Answers 2
It would be much easier for TS to infer the types if you put your callback in second place.
I mean const map = obj => f => {}
Also I slightly refactored your function in order to reduce complexity:
type Primitives = string | number;
const apply = <
Key extends string,
Value,
>(obj: Record<Key, Value>) =>
<Result,>(cb: (value: Value) => Result) =>
(Object.keys(obj) as Array<Key>)
.reduce((acc, elem) => ({
...acc,
[elem]: cb(obj[elem])
}), {} as Record<Key, Result>)
type Abc = {
a: number,
b: number,
c: number,
};
const foo = { a: 1, b: 2, c: 3 }; // Abc
const map = apply(foo)
const double = (x: number) => x * 2
const shout = <T extends { toString: () => string }>(x: T) => `${x.toString()}!`
const promisify=<T,>(arg:T)=>Promise.resolve(arg)
const bar = map(double) // Record<"a" | "b" | "c", number>
const foobar = map(shout); // Record<"a" | "b" | "c", string>
const baz = map(promisify) // Record<"a" | "b" | "c", Promise<number>>
-
\$\begingroup\$ Well, you have change both the behaviour of the function and the behaviour of the types, so I would not consider that a refactor. There is a discussion to be had about the types: do you find overzealous to preserve the name of the type as I did? Concerning the order of the arguments, it's important that data comes last if you want to partially apply the behaviour. It is a generally more useful pattern in functional programming. It's the data that flows. \$\endgroup\$geoffrey– geoffrey2021年06月09日 13:31:02 +00:00Commented Jun 9, 2021 at 13:31
-
\$\begingroup\$ Ok, I will consider it next time \$\endgroup\$captain-yossarian– captain-yossarian2021年06月09日 16:00:33 +00:00Commented Jun 9, 2021 at 16:00
I gave it another go, which achieves the same functionality
type Transform<From, To> = (
value: From,
index: number,
entries: [unknown, From][]
) => To;
type Choose<A, B> = A extends B ? A : B;
const map = <From, To>(t: Transform<From, To>) =>
<Input extends Record<string, From>>(obj: Input) =>
Object.fromEntries(
Object.entries(obj).map(
([key, val], i, arr) => [key, t(val, i, arr)])
) as Choose<Input, Record<keyof Input, To>>;
I don't like the indentation, I don't like to have types in the function body, but I have to admit it's a lot better than my previous attempt which was creating too many abstractions, and also bad ones (mapping
isn't so natural)
Working with Object.entries/fromEntries is also problematic because the keys are always going to be of type string
when I wanted keyof Iput
, hence the as
which I also don't like.
Explore related questions
See similar questions with these tags.