Skip to content

Instantly share code, notes, and snippets.

@ukslim
Last active October 12, 2020 11:37
Show Gist options
  • Save ukslim/93d44138909127a5e4d7de1d650b32be to your computer and use it in GitHub Desktop.
Save ukslim/93d44138909127a5e4d7de1d650b32be to your computer and use it in GitHub Desktop.
Ramda and Typescript

Ramda and TypeScript

... when @types/ramda is installed.

Type inference for currying:

const curried = add(1);
// infers: const curried: (b: number) => number

...

const getName = prop('name');
// infers: const getName: <T>(obj: Record<"name", T>) => T

This is promising...

const name = getName({ name: 'Harry'})
// infers string

... and this is magic! (Looks like it's explained at https://www.typescriptlang.org/docs/handbook/advanced-types.html#index-types )

Type inference for compose():

const data = [
    { name: 'John', age: 4},
    { name: 'Paul', age: 5},
];

const nameAtIndex = n => compose(
    prop('name'),
    nth(n),
);
// infers: const nameAtIndex: (n: any) => <T>(x0: unknown) => T

const name = nameAtIndex(1)(data);
// infers: const name: unknown

const s : string = name;
// compile error: Type 'unknown' is not assignable to type 'string'.

const s : string = name as string; // OK

So in at least this case, TS can't join up all the types.

But we can add a small amount of explicit typing and it'll infer the rest:

interface Person {
    name: string,
    age: number,
}

const data : Person[]  = [
    { name: 'John', age: 4},
    { name: 'Paul', age: 5},
];

const nameAtIndex = (n : number) => compose(
    prop('name'),
    nth(n) as (data: Person[]) => Person,
);
// infers: const nameAtIndex: (n: number) => (x0: Person[]) => string

const name = nameAtIndex(1)(data);
// infers: const name: string

R.prop and interfaces

interface Person {
    name: string,
    age: number,
}

const getName = (p : Person) => prop('name', p); 
// infers: const getName: (p: Person) => string

const getAge = (p : Person) => prop('age', p); 
// infers: const getAge: (p: Person) => number

const getBloodType = (p : Person) => prop('bloodType', p);
// compile error: 'No overload matches this call'

That's voodoo! And useful! But it doesn't work with a pointfree style:

const getName = prop('name');
// infers: const getName: <T>(obj: Record<"name", T>) => T

... because we haven't told it that our curried function expects a Person

Maybe it's cleaner to explicitly type our curried function:

const getName : (p: Person) => string = prop('name');

... because then we get compile errors if we get it wrong:

const getName : (p: Person) => string = prop('age');
// Type '<T>(obj: Record<"age", T>) => T' is not assignable to type '(p: Person) => string'.
// Type 'number' is not assignable to type 'string'

R.props

interface Person {
    name: string,
    age: number,
    job?: string,
}

const ringo : Person = { name: 'Ringo', age: 28, job: 'drummer' };

const nameAge = props(['name', 'age'], ringo);
// infers: const nameAge: (string | number)[]

Not sure what else we can do with this... It does what you'd expect.

It also complains if you forget to give the params as an array.

R.pick

const nameAge = pick(['name', 'age'], data[1]);
// infers: const nameAge: Pick<Person, "name" | "age">
const age = prop('age', nameAge);
// infers: const age: string

Nice!

interface NameAge {
    name: string,
    age: number,
}

const nameAge2 : NameAge = nameAge;

... so it's happy to cast from Pick<Person, "name" | "age"> to NameAge. Nice!

Based on this we can use Ramda to implement a typed getNameAge:

const getNameAge : (p: Person) => NameAge = pick(['name', 'age']);

... and it'll error if we screw it up:

const getNameAge : (p: Person) => NameAge = pick(['name', 'job']);
// Property 'age' is missing in type 'Pick<Person, "name" | "job">' but required in type 'NameAge'.

Nice!

R.reduce

Might as well try the fundamentals...

const nameAges = reduce(
    (acc, value) => append(getNameAge(value), acc),
    [],
    data
    );
// infers: const nameAges: any[]

Too broad. We can clamp it down by explicitly typing the base accumulator:

const nameAges = reduce(
    (acc, value) => append(getNameAge(value), acc),
    [] as NameAge[] ,
    data
    );
// infers: const nameAges: NameAge[]

Now if we try to make it a function by currying:

const nameAges = reduce(
    (acc, value) => append(getNameAge(value), acc),
    [] as NameAge[] ,
);
// compile error:
//    (parameter) value: unknown
//    Argument of type 'unknown' is not assignable to parameter of type 'Person'.

It's telling us it can't guarantee that value is a Person, so it could call getNameAge with an incompatible parameter. We can fix it like this:

const nameAges = reduce(
    (acc, value : Person) => append(getNameAge(value), acc),
    [] as NameAge[] ,
);
// infers: const nameAges: (list: readonly Person[]) => NameAge[]

Nice!

Or:

const nameAges : (list : Person[]) => NameAge[] = reduce(
    (acc, value) => append(getNameAge(value), acc),
    [],
);

Note that having given an explicit type to the function as a whole, I can remove the types info I added in the body, because now TypeScript can infer those.

reduce and generic functions

Thanks to @whismura on gitter for this:

const n : number = reduce(max, -Infinity, [1,2,3]);
// compile error: Type 'Ord' is not assignable to type 'number'.

This is because max is declared as <T extends Ord>(T a, T b) => T. The declaration of reduce does not allow TS to infer that max will be called with numbers, so all it knows is that the function as a whole returns Ord. I'm not certain whether this is an inherent limitation of the type system, or a flaw in @types/ramda's declaration of reduce.

It can be fixed with:

const n : number = reduce(
    max as (a: number, b: number) => number,
    -Infinity,
    [1,2,3]
);

... or ...

const n : number = reduce(
    (a,b) => max<number>(a,b),
    -Infinity,
    [1,2,3]
);

Alas reduce(max<number>, -Infinity, [1,2,3]) is a syntax error -- I'm on the lookout for a better way.

R.transduce

Transduce also works, and helpfully gave an error when I forgot the map() from the transformer:

const nameAges : (list : Person[]) => NameAge[] = transduce(
    map(getNameAge),
    (acc, value) => append(value, acc),
    [],
);

However, you might typically use flip(append) in the second param, and this doesn't work without type help.

const nameAges : (list : Person[]) => NameAge[] = transduce(
    map(getNameAge),
    flip(append),
    [],
);
// Argument of type '(arg1: readonly NameAge[][], arg0?: unknown) => <T>(list:
// readonly T[]) => T[]' is not assignable to parameter of type '(acc: readonly
// unknown[], val: unknown) => unknown[]'.

In fact, I could only make it work with:

const nameAges : (list : Person[]) => NameAge[] = transduce(
    map(getNameAge),
    flip(append as (value: NameAge[], acc: NameAge[][] ) => NameAge[][]),
    [],
);

... which is less clean than the first attempt (acc, value) => append(value, acc)

Possibly a pattern emerging here!

I keep encountering where I write some ordinary Ramda, and either:

  • The IDE shows a compiler error because of types (this is good!)
  • The IDE shows an overly broad type for the variable or function I've created

Then I fix it by adding type info to some aspect of the function body.

Then I hover over the declaration in the IDE, and it tells me the type it's inferred.

Then I take that type declaration, and add it as an explicit declaration. This is nice because it's documentation for readers of the code. And it means if you modify that function such that its types change, the compiler will tell you.

Then I remove the type info from the function body (leaving it in the declaration). And it still compiles, because the compiler can infer those types from the signature.

Inklings of a style convention

I'm thinking that any function exported from a module should have an explicit type signature. It's documentation for the caller, and some protection against regression.

"Private" functions -- those not exported by the module, may have an explicit type, if it helps the compiler, or makes the code more readable. But there will be plenty of cases where it's not needed, so don't only do it where it helps.

Deeper nested Ramda

Let's try a compose inside a compose:

const sillyGetName: (x0: Person[]) => string[] = compose(
    map(
        compose(
            prop('name'),
            pick(['name']),
        ),
    ),
    nameAges,
);
// Compiler error: 
// Type '(x0: Person[]) => unknown[]' is not assignable to type '(x0: Person[]) => string[]'.
// Type 'unknown[]' is not assignable to type 'string[]'.
// Type 'unknown' is not assignable to type 'string'

This is for the same reason the compose example at the top inferred a return type of unknown. We are not telling the inner compose what type is coming in, so it doesn't know what type pick will return.

We can make it compile by typing pick([name]):

const sillyGetName: (x0: Person[]) => string[] = compose(
    map(
        compose(
            prop('name'),
            pick(['name']) as (p : NameAge) => Pick<NameAge, 'name'>,
        ),
    ),
    nameAges,
);

... or in this case the less complex type

const sillyGetName: (x0: Person[]) => string[] = compose(
    map(
        compose(
            prop('name'),
            pick(['name']) as (p : NameAge) => { name: string },
        ),
    ),
    nameAges,
);

... and this is pretty good because changing 'name' to 'age' anywhere in there causes a compile error.

But it's also a reminder to extract-function for cleaner code:

const pickPropName = compose(
    reverse,
    prop('name'),
    pick(['name']) as (n: NameAge) => { name: string },
);

const sillyGetName: (p: Person[]) => string[] = compose(
    map(pickPropName),
    nameAges,
);

You still have to explicitly type the last element of compose if it can't be inferred, but it's less buried.

In some cases you can make things work tidily by making the last element of the compose:

identity as (n: NameAge) => NameAge

... but it doesn't aways seem to do the job, and I haven't quite worked out why yet.

Hurried conclusion

I expected things to be much worse. I expected R.prop() to return a type of any or unknown and for Ramda coding in TypeScript to be essentially as loosely typed as it is in JS.

I think there's potential for Ramda coding in TS to be really productive, and quite a lot safer than it is in JS. I've spotted a few patterns we'd need to use:

  • use as to type the last element of a compose when the compiler can't infer.
  • explicitly type exported functions: that way the compiler is telling you whether you're right, not just reporting how it is.
  • learn to work around the functions that don't play nicely -- e.g. flip in the transduce example

If we try and do some real work in Ramda + TS, I'm sure we'll find more niggles, more solutions, and more effective practices.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment