Ok so I was trying to write a type-aware "omit" function.
After a long, long reading of stack-overflow I came up with the following solution that works (yay):
const omit = <
T extends Record<string, unknown>,
Del extends keyof T,
U = { [Key in Exclude<keyof T, Del>]: T[Key] }
> (obj: T, ...props: Del[]): U =>
Object.entries(obj).reduce((acc, [key, value]): U => {
for (const del of props) {
if (del === key) {
return acc;
}
}
return { ...acc, [key]: value };
}, {} as U);
And if I write omit({ a: 1, b: 2 }, 'a');
then tsc understands it very well:
But I prefer the functional-programming way of writing these sorts of things, with a function that takes the props to omit and then returns a function that will take an object and return it without the specified props (useful for composition).
So I tried writing it like so, it's almost the same code:
const fpOmit = <
T extends Record<string, unknown>,
Del extends keyof T,
U = { [Key in Exclude<keyof T, Del>]: T[Key] }
> (...props: Del[]) => (obj: T): U =>
Object.entries(obj).reduce((acc, [key, value]): U => {
for (const del of props) {
if (del === key) {
return acc;
}
}
return { ...acc, [key]: value };
}, {} as U);
There are no errors, no warnings, but this time calling fpOmit('a')({ a: 1, b: 2 });
doesn't infer the expected type at all:
What am I doing wrong here?
When a generic function is called, all of its type parameters must be specified; either manually by the caller (like fn<MyObjType, MyKeyType>(...)
) or via inference from the arguments passed to the function (and, occasionally, contextually from the expected return type).
In your original omit()
function:
declare const omit: <T extends Record<string, unknown>, D extends keyof T>(
obj: T, ...props: D[]
) => { [K in Exclude<keyof T, D>]: T[K]; }
the compiler can infer both the T
and D
type parameters from the obj
and props
arguments, and everything works out well:
const result = omit({ a: 1, b: 2 }, "a");
// const result: { b: number; }
But in your curried version:
declare const fpOmit: <T extends Record<string, unknown>, D extends keyof T>(
...props: D[]) => (obj: T) => { [K in Exclude<keyof T, D>]: T[K]; }
while you are writing the following on one line:
const fpResult = fpOmit('a')({ a: 1, b: 2 });
it's still a pair of function calls, like this:
const omitA = fpOmit('a');
const fpResult = omitA({ a: 1, b: 2 });
And when you call fpOmit('a')
, both of its type parameters T
and D
must be specified. But while the compiler can infer D
from the 'a'
input, it has no idea at all what to infer for T
, and thus falls back to the constraint:
const omitA = fpOmit('a');
// const fpOmit: <Record<string, unknown>, "a">(
// ...props: "a"[]) => (obj: Record<string, unknown>) =>
// { [x: string]: unknown; }
// const omitA: (obj: Record<string, unknown>) => { [x: string]: unknown; }
And once that happens, it's all over. The return type of omitA()
does not depend on the type of the object passed into it; it's just { [x: string]: unknown; }
no matter what:
const fpResult = omitA({ a: 1, b: 2 });
// const fpResult2: { [x: string]: unknown; }
So we can't do it this way.
What you need to do instead is change the scope of your generic type parameters so that they only need to be specified when there is enough information available to do so. So the T
parameter needs to be moved to the call signature of the function returned by fpOmit()
. This also means you have to rephrase your constraints; you have to express T
in terms of D
and not vice versa:
declare const fpOmit: <D extends PropertyKey>(
...props: D[]) => <T extends Record<D, unknown>>(
obj: T) => { [K in Exclude<keyof T, D>]: T[K]; }
Now everything works:
const fpResult = fpOmit('a')({ a: 1, b: 2 });
// const fpResult: { b: number; }
If you break it apart as before, you can see why:
const omitA = fpOmit('a');
// const fpOmit: <"a">(
// ...props: "a"[]) => <T>(obj: T) =>
// { [K in Exclude<keyof T, "a">]: T[K]; }
// const omitA: <T extends Record<"a", unknown>>(
// obj: T) => { [K in Exclude<keyof T, "a">]: T[K]; }
The function returned from fpResult()
is still generic in the type T
of obj
, and so the return type of omitA()
will depend on that type:
const fpResult2 = omitA({ a: 1, b: 2 });
// const fpResult2: { b: number }