Home > Net >  Defining a generic TypeScript type with argument injection
Defining a generic TypeScript type with argument injection

Time:09-18

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 Examples or call them more often.


Playground link to code

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

Playground

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

Playground

  • Related