Reputation: 8787
I've built a method that will concatentate an array with another array that's passed in. The array lives on a parent object called state
which is generically typed in my method.
My method is along the lines of this:
export const mutateConcat = <T, K extends keyof T>(state: T, key: K, val: T[K]) => {
((state[key] as unknown) as any[]) = ((state[key] as unknown) as any[]).concat(val);
}
Obviously this is ugly, and I'd like to get rid of the casting to unknown
and any[]
. I'd also like to be able to determine the type of the array contents.
Basically, I need to ensure that T[K] is an Array, and ideally I'd like to assign the type of the contents of the array to another generic parameter, say U
.
I have tried <T, K extends keyof T, U extends Array<T[K]>>
but that assigns U
to the type of the array itself, not to the type of the contents of the array.
Is what I'm asking possible? Can extends keyof
be filtered to only keys that are array types, and can the types of the contents of those arrays be assigned to another type parameter?
Thanks in advance
Edit:
So, I've managed to get the type of the array by keying off of the val
variable. So the following:
export const mutateConcat = <T, U, K extends keyof T>(state: T, key: K, val: T[K] & U[], arrayDistinctProperty?: keyof U) => {
// U now contains the array type, and `arrayDistinctProperty` is typed to a property on that array type object
((state[key] as unknown) as any[]) = ((state[key] as unknown) as any[]).concat(val);
}
However, this doesn't solve the ugliness of the casting, because typescript doesn't know for sure that T[K]
is an array type. Is it possible to tell it to only allow my key
parameter to be an array type on T
?
Upvotes: 2
Views: 2504
Reputation: 141512
/**
* Push into the existing array.
*/
export const mutateConcat1 = <
TState extends Record<TKey, unknown[]>,
TKey extends string,
>(
state: TState,
key: TKey,
newArray: TState[TKey]
) => {
state[key].push(...newArray);
};
The cast with as
here might be unavoidable. Without it we receive, Type 'unknown[]' is not assignable to type 'TState[TKey]'. That is because we told the compiler that the array is of type unknown[]
in the Record
. I tried to infer
the array type but was not able to make it work. The cast seems very safe though, given that we know for sure that both state[key]
and newArray
are of the same type. If only I knew how to explain that to the compiler.
/**
* Overwrite the original array with concat.
*/
export const mutateConcat2 = <
TState extends Record<TKey, unknown[]>,
TKey extends string,
>(
state: TState,
key: TKey,
newArray: TState[TKey]
) => {
state[key] = state[key].concat(newArray) as TState[TKey];
};
/**
* Test Cases
*/
const goodState = { array1: [1, 2, 3] };
const badState = { array1: new Date() };
// ok
mutateConcat1(goodState, 'array1', [4, 5, 6]);
mutateConcat2(goodState, 'array1', [4, 5, 6]);
// errors
mutateConcat1(goodState, 'array1', ['4', 5, 6]);
mutateConcat1(goodState, 'array2', [4, 5, 6]);
mutateConcat1(badState, 'array1', [4, 5, 6]);
mutateConcat2(goodState, 'array1', ['4', 5, 6]);
mutateConcat2(goodState, 'array2', [4, 5, 6]);
mutateConcat2(badState, 'array1', [4, 5, 6]);
Upvotes: 1
Reputation: 8787
So, between this and a reddit thread I believe I have the best solution for my needs. Here is what I've come up with:
const mutateConcat = <
TState extends Record<TKey, any[]>,
TKey extends keyof TState,
TElement extends TState[TKey] extends Array<infer TEl> ? TEl : never
>(state: TState, key: TKey, val: T[K], elementProperty?: keyof TElement) => {
state[key].push(...val);
if (elementProperty) {
// filter state[key] to have distinct values off of element property
}
}
I chose to use any[]
in the TState
because there were other issues when I tried TElement[]
because the state could have any kinds of properties on them and it was not aligning with the way it was being called. This method is actually being used as a vuex mutation method as a higher order function so I'm not concerned with extreme type safety on the state parameter. The keyof
parameters are the important ones as those will be passed by developers, and this solution enforces that both are valid for the objects being passed.
Edit: Here's the full function for anyone interested:
const mutateConcat = <
TState extends Record<TKey, any[]>,
TKey extends keyof TState,
TElement extends TState[TKey] extends Array<infer TEl> ? TEl : never
>(
key: TKey,
sessionKey?: string,
distinctProperty?: keyof TElement,
) => (state: TState, val: TElement[]) => {
state[key].push(...val);
if (distinctProperty) {
removeDuplicates(state[key], distinctProperty);
}
if (sessionKey) {
setSessionItem(sessionKey, state);
}
};
Upvotes: 0
Reputation: 328142
The other answer is correct, but I wanted to add that you can get it to work if you make state
a relatively concrete Record<K, U[]>
type, instead of the more generic T extends Record<K, U[]>
type. This helps the compiler see the assignment as safe, since T extends {a: string[]}
could be {a: Array<"lit">}
, and then obj.a.push("oops")
would be an error:
const mutateConcat = <K extends keyof any, U>(
state: Record<K, U[]>,
key: K,
val: U[]
) => {
state[key] = state[key].concat(val); // okay
};
The only problem I see there is if you pass object literals into the state
parameter when you call mutateConcat()
, you will get presumably undesirable excess property checking on non-array properties:
mutateConcat({ a: [1, 2, 3], b: 1 }, "a", [4, 5, 6]); // excess property checking 😢
And anything I do to the signature of mutateConcat()
to address this causes the compiler to be too confused inside the implementation and you're back to casting asserting. If you still want to go this way you can avoid excess property checking by not using fresh object literals, as in:
const obj = { a: [1, 2, 3], b: 1 };
mutateConcat(obj, "a", [4, 5, 6]); // okay
Whether this satisfies your use case is up to you. Okay, good luck!
Upvotes: 2