Issue
I am trying to make a reusable includes
function like so
type Primitive = string | boolean | number;
export const includesByAttribute = (
attr: string,
list: { [attr]: Primitive }[],
val: { [attr]: Primitive },
) => {
if (list.length > 0) return list.find((obj) => obj[attr] === val[attr]);
return false;
};
hoping to define the second and third argument's type based on the first argument. Is there a way to enforce this?
I tried
export const includesByAttribute = (
attr: string,
list: { [attr: string]: Primitive }[],
val: { [attr: string]: Primitive },
) => {
if (list.length > 0) return list.find((obj) => obj[attr] === val[attr]);
return false;
};
and when calling it, the 'value' argument is not considered, allowing the attribute of 'x' to exist in the second and third args
includesByAttribute('value', [{ x: 1 }], { x: 2 })) // compiles fine but doesn't achieve goal
Solution
In order for this to work you need to make your function generic. That's the only way for you to have allowed types of multiple inputs depend on each other. Here's one way to write it:
const includesByAttribute = <
K extends PropertyKey,
T extends Record<K, string | number | boolean>
>(
attr: K,
list: T[],
val: NoInfer<T>,
) => {
if (list.length > 0) return list.find((obj) => obj[attr] === val[attr]);
return false;
};
Here the type parameter K
corresponding to the attr
input is constrained to PropertyKey
, meaning it should be keylike (it's string | number | symbol
; you could make it just string
if you want.)
And the type parameter T
corresponding to the elements of the list
input is constrained to Record<K, string | number | boolean>
(using the Record
utility type) meaning that it should at least have a string | number | boolean
property at the key K
corresponding to attr
. This doesn't require that every property of T
be Primitive
, just the one at key K
.
You also want val
to be of type T
, but I've written NoInfer<T>
to indicate that you don't want the compiler to infer T
from val
. If you left it as T
, then the compiler would infer T
from both list
and val
and it might allow extra keys to exist in val
that don't exist in the element of list
. That is, you want the compiler to infer T
from list
only, and then just check val
against it. That way if you add extra keys to val
, the compiler will complain.
So NoInfer<T>
is meant to evaluate to T
, but "block" or "shield" inference. As of TypeScript 5.3 there is no built-in implementation of NoInfer<T>
, and there was a longstanding open issue at microsoft/TypeScript#14829 asking for it. Various user implementations of it exist in the discussion of that issue which work for various use cases. One simple one is mentioned in a comment: just intersect with an empty object type {}
to lower the inference priority:
type NoInfer<T> = T & {}
This works for your example. But the next version of TypeScript will very likely include microsoft/TypeScript#56794, which implements NoInfer<T>
natively. At which point you can discard the definition about and it will still behave.
Okay, let's test it:
includesByAttribute('x', [{ x: 1 }], { x: 2 }); // okay
includesByAttribute('value', [{ value: 1, x: 1 }], { value: 2, x: 2 }); // okay
includesByAttribute('value', [{ x: 1 }], { x: 2 }); // error, x is not known
includesByAttribute('value', [{ value: 1, x: 1, z: 8 }], { value: 2, x: 2 }); // error, z missing
Looks good. The compiler accepts the first two calls and rejects the second two calls, as desired.
Answered By - jcalz
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.