... when @types/ramda
is installed.
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 )
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
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'
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.
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!
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.
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 number
s, 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.
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)
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.
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.
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.
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 acompose
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 thetransduce
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.