I am trying to describe a missing property in TypeScript generics. I wrote a curried example function example
that receives two arguments. The first argument is a result producer function f
that is able to produce the wanted result. The second argument is an argument object a
that contains all needed arguments except one. The missing property bar
needs to be added into the argument object before it an be used to compute the result. However, no matter what way I write the type signature, I always get a type error when attempting to construct the complete argument object.
type Example = <A extends { bar: string }, R>(
f: (a: A & { bar: string }) => R,
) => (a: Omit<A, 'bar'>) => R;
const example: Example = (f) => (a) => f({ ...a, bar: 'bar' });
The errors I keep getting seem to indicate that the result function f
might not accept an arbitrary string and might have special requirements for the string it receives as the injected bar
argument. I wish to strip the result function f
from the priviledge of being able to define arbitrary extensions to the string. I want to assert that the result function f
needs to deal with any string it is given. However, I don't mind the result function f
defining arbitrary extensions for other parts of the argument object.
CodePudding user response:
The error message at f({ ...a, bar: 'bar' })
says
/* Argument of type 'Omit<A, "bar"> & { bar: string; }' is not
assignable to parameter of type 'A & { bar: string; }'. */
because the value { ...a, bar: "bar" }
is inferred to be Omit<A, "bar"> & { bar: string }
, but you've got the f()
function annotated that it takes a value of type A & { bar: string }
. The compiler cannot be sure that those types are the same (and won't be the same if A
has a bar
property narrower than string
), so it complains.
That error message implies that everything would be fine if f()
accepted Omit<A, "bar"> & { bar: string }
instead. Indeed, if you make that change, it compiles without error:
type Example = <A extends { bar: string }, R>(
f: (a: Omit<A, 'bar'> & { bar: string }) => R,
) => (a: Omit<A, 'bar'>) => R;
const example: Example = (f) => (a) => f({ ...a, bar: 'bar' }); // okay
That might be good enough for you, but there are a few more changes I'd make here. The type of { ...a, bar: 'bar' }
would be Omit<A, "bar"> & { bar: string }
even if a
were of type A
, because that's what spreading with generics does in TypeScript. Presumably this is what you thought a
was in the first place, and only put Omit
there to fix things. So let's eliminate that:
type Example = <A extends { bar: string }, R>(
f: (a: Omit<A, 'bar'> & { bar: string }) => R,
) => (a: A) => R;
const example: Example = (f) => (a) => f({ ...a, bar: 'bar' }); // okay
And finally, there's no reason why A
must have a bar
property of type string
. Even if A
has no bar
property, or an incompatible bar
property, the function should still behave as desired (since { ...a, bar: 'bar' }
will overwrite any bar
property on A
, or provide one if A
doesn't have one). So we can remove the constraint on A
:
type Example = <A, R>(
f: (a: Omit<A, 'bar'> & { bar: string }) => R,
) => (a: A) => R;
const example: Example = (f) => (a) => f({ ...a, bar: 'bar' }); // okay
The only issue I see now is that it will be hard for the compiler to infer A
properly by a call to example
:
const exampled = example((x: { baz: number, bar: string }) =>
x.baz.toFixed(2) x.bar.toUpperCase()
); // error! A is inferred as unknown, and so
// Argument of type '(x: { baz: number; bar: string;}) => string' is not
// assignable to parameter of type '(a: Omit<unknown, "bar">
// & { bar: string; }) => string'.
The compiler just can't infer A
from a value of type Omit<A, "bar"> & { bar: string }
, and I can't seem to rephrase that type in a way that works.
You can, of course, manually specify A
(and also R
, because there's no partial type argument inference as requested in ms/TS#26242, at least not as of TS4.8):
const exampled = example<{ baz: number }, string>(
x => x.baz.toFixed(2) x.bar.toUpperCase()
); // okay
// const exampled: (a: { baz: number; }) => string
And verify that it works as expected:
console.log(exampled({ baz: Math.PI })) // "3.14BAR"
So that's, I guess, the answer to the question as asked.
The issue of inference here seems to be out of scope. If you want inference to work for callers, you pretty much need to do something like this:
type Example = <A, R>(
f: (a: A) => R,
) => (a: Omit<A, "bar">) => R;
const example: Example = (f) => (a) => f({ ...a, bar: 'bar' } as
Parameters<typeof f>[0]); // okay
const exampled = example((x: { baz: number, bar: string }) =>
x.baz.toFixed(2) x.bar.toUpperCase()
); // okay
/* const exampled: (a: Omit<{ baz: number; bar: string; }, "bar">) => string
console.log(exampled({ baz: Math.PI })) // "3.14BAR"
So we're sacrificing some type safety in the implementation of example
(using a type assertion to claim that the argument to f
is the right type, which is technically not 100% safe but fine in practice) to get nice behavior for those who call it. It's possible that this is the way you want to proceed, depending on whether you're going to implement Example
s or call them more often.
CodePudding user response:
I'm pretty sure this is what as
is for, it complains 'A' could be instantiated with a different subtype of constraint '{ bar: string; }'
but given you're cloning the properties à la {...a, bar:'bar'}
you know it'll match type A
; all you're trying to do is enforce type-checking to code using the resultant function. So long as you're sure of what you're doing in your example()
middleware (e.g. A
isn't an instantiated class
object or something where you'll need to do a little more than just enumerate properties) there's no need to make the compiler 110% happy.
const barFunc = ({foo, bar} :{foo :string, bar :string}) => (
`${foo} ${bar}`
);
function example<
A extends {bar :string},
R
>(
f :(a :A) => R
) {
return (a :Omit<A, 'bar'>|A) => (
f({...a, bar:'bar'} as A)
);
}
const test = example(barFunc);
console.log(test({foo:'missing'}));
console.log(test({foo:'overwritten', bar:'ignored'})); //I added `|A` to the typing just in case you're going to be feeding existing calls to f() instead now to your middleware
Basically it's complaining "hey, what if 'A' isn't {bar :string; [others :string] :unknown;}
but actually class { bar :string; memberFunction() { return this.bar; } }
for this f
?". If you're sure it's a basic object, {...a}
is fine, and as
just lets the compiler know you know what you're doing, but everyone calling example()
needs to be aware of this (which is fine if it's just you and example
is a non-exported function in a module of yours).
Honestly, if you're not using this to blanket-override a bunch of different functions you should just tailor-wrap them so the compiler just knows what's going on and doesn't need any hints in the first place.
const barFunc = ({foo, bar} :{foo :string, bar :string}) => (
`${foo} ${bar}`
);
type A = Parameters<typeof barFunc>[0];
function test(obj :Omit<A, 'bar'>|A) {
return barFunc({...obj, bar:'bar'});
}
console.log(test({foo:'missing'}));
console.log(test({foo:'overwritten', bar:'ignored'})); //I added `|A` to the typing just in case you're going to be feeding existing calls to f() instead now to your middleware
CodePudding user response:
The problem here is that the parameter type A extends { bar: string }
could be instantiated for example by type { bar: 'not bar' }
. If it is then trying to pass the argument { ...a, bar: 'bar' }
the function f
will throw an error. To illustrate this this is an example:
type E = typeof a<{ bar: 'not bar' }, unknown>
const example1: E = (f) => (a) => f({ ...a, bar: 'bar' })
// The expected type comes from property 'bar'
// which is declared here on type '{ bar: "not bar"; }'
const example2: E = (f) => (a) => f({ ...a, bar: 'not bar' }) // Ok
This will work for you:
type Example1 = <F extends (a: any) => R, R>(
f: F & ((a: Parameters<F>[0]) => R),
) => (a: Omit<Parameters<F>[0], 'bar'>) => R;
const example3: Example1 = (f) => (a) => f({ ...a, bar: 'bar' }); // Ok
const q1 = example3((p: {a: 3, bar: 'b'}) => 3)
// q1: (a: Omit<{
// a: 3;
// bar: 'b';
// }, "bar">) => number
const a1 = q1({ a: 3 }) // number
const a2 = q1({ a: 4 }) // Error: Type '4' is not assignable to type '3'
const a3 = q1({ a: 3, foo: 1 }) // Error
And if you need that f
function has a mandatory type of argument which assigns to { bar: string }
this is an enhancement:
type Example = <F extends (a: any) => R, R>(
f: F &
((a: Parameters<F>[0]) => R) &
(Parameters<F>[0] extends { bar: string } ? unknown : never)
) => (a: Omit<Parameters<F>[0], "bar">) => R;
const example: Example = (f) => (a) => f({ ...a, bar: "bar" }); // Ok
const q1 = example((p: { a: 3; bar: "b" }) => 3);
// q1: (a: Omit<{
// a: 3;
// bar: 'b';
// }, "bar">) => number
const q2 = example((p: { a: 3; foo: "b" }) => 3); // Error
const a1 = q1({ a: 3 }); // number
const a2 = q1({ a: 4 }); // Error: Type '4' is not assignable to type '3'
const a3 = q1({ a: 3, foo: 1 }); // Error