2
\$\begingroup\$

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 type Abc
  • foobar is holding on to Abc'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!

\$\endgroup\$

2 Answers 2

1
\$\begingroup\$

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>>
\$\endgroup\$
2
  • \$\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
    Jun 9, 2021 at 13:31
  • \$\begingroup\$ Ok, I will consider it next time \$\endgroup\$ Jun 9, 2021 at 16:00
0
\$\begingroup\$

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.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.