So you have a constraint that when you call ifAlreadyExists(), you need the type of value and the type of data[n][key] (for numeric n) need to either both be assignable to string or both be assignable to number.
The easiest way to enforce this for callers is probably to make it an overloaded method with two call signatures, one for each possibility:
// call signatures
ifAlreadyExists<K extends PropertyKey, H extends PropertyKey>(
data: Array<Partial<Record<K, string>> & Partial<Record<H, string | number>>>,
key: K, value: string,
idKey: H, idValue: string | number
): boolean;
ifAlreadyExists<K extends PropertyKey, H extends PropertyKey>(
data: Array<Partial<Record<K, number>> & Partial<Record<H, string | number>>>,
key: K, value: number,
idKey: H, idValue: string | number
): boolean;
The first call signature handles the string case and the second signature handles the number case. Splitting into overloads prevents weird situations where you have values of a union type like string | number:
this.ifAlreadyExists(
[{ a: Math.random() < 0 ? "hey" : 7 }], // error
// ~ <-- you want a compiler error here
"a", "hey", "a", 100
);
In that example, the inferred type of data[0].a is something like string | number, and if you accept that, you will end up accepting a case where value is a string while data[0].a turns out to be a number, and the check typeof value === 'string' is not a sufficient check. In our discussion in comments you indicated that you really don't want to have to check both typeof value and typeof item[key] for each item element of data, so it's probably best to prevent the method from being called that way.
Anyway, you can see that the call signatures are generic in two type parameters: K corresponds to the property key passed in as key, and H corresponds to the property key passed in as idKey. From these parameters, we can express the type of data as:
Array<Partial<Record<K, string>> & Partial<Record<H, string | number>>>
(for the first call signature) which means that it will accept any parameter that is an array of elements having an optional string-valued property at key K (this changes to number for the second call signature) and an optional string-or-number-valued property at key H. This frees us from having to deal with a third type parameter T corresponding to the type of data. We could have done this, but it's not necessary (at least with the use cases presented in the question).
Let's make sure that callers are happy:
const carList: Array<Car> = [{ id: 111, horsePower: 1337, date: new Date() }];
this.ifAlreadyExists(carList, 'horsePower', 1337, 'id', 100);
const bookList: Array<Book> = [{ id: '222', title: 'Title 1', date: new Date() }];
this.ifAlreadyExists(bookList, 'title', 'Title 1', 'id', '200');
Those compile fine, so it looks good.
So the call signatures are fine. Now we need the implementation:
ifAlreadyExists(
data: Array<Record<string, string | number | undefined>>,
key: string, value: string | number, idKey: string, idValue: string | number
) {
if (typeof value === 'string' && typeof key === 'string') {
return (
data.filter(
(item) =>
item[idKey] !== idValue &&
item[key] &&
value &&
(item[key] as string).trim().toLowerCase() ===
value.trim().toLowerCase())
).length === 0;
} else {
return (
data.filter(
(item) =>
item[idKey] !== idValue &&
item[key] &&
value &&
item[key] === value
).length === 0
);
}
}
Overload implementations are checked more loosely by the compiler than call signatures, mostly because it can't really afford to do the kind of analysis necessary to do it properly and it errs on the side of looseness; see this answer for details.
So for data we can widen the type all the way to Array<Record<string, string | number | undefined> and the compiler will happily let us index into every element of data with any key we want, and read or write any value of a string, number, or undefined type, or any union of those. This makes most of the implementation code compile without error, except for:
item[key].trim().toLowerCase() // error!
as you had in your original question. And that's because the compiler still thinks item[key] could be number even though typeof value === 'string'. Yes, we prevented that from happening by having those two call signatures, but the implementation doesn't know about the call signatures. It only knows that data holds elements of bags of string | number | undefined properties and has no idea that this the type of the property at key key will be the same as that of value. And there's really no great way to convince the compiler of this; it would be easier to check typeof item[key] than it would be to rewrite the code so that the compiler would just know it ahead of time. In other words: value is of a union type, and item[key] is of a union type, and the compiler treats them as independent when they are correlated, and correlated union types are not really supported by the compiler; see microsoft/TypeScript#30581 for a relevant issue.
The easiest thing to do here by far is what I normally do in the face of correlated unions; use a type assertion to just tell the compiler the type of item[key] and move on:
(item[key] as string).trim().toLowerCase() // okay
Both the type assertion and the overload implementation allow you to write code the compiler can't verify as type safe. The fact that there are no compiler warnings is therefore a convenience and not a guarantee of type safety. When you write overload implementations or type assertions, you are taking some of the responsibility of checking type safety away from the compiler. There's really no other good option in this case, since the compiler is unable to handle this responsibility. But it means you should be especially careful and double check that what you are doing is safe. Obviously you could have written
(item[key] as number).toFixed(2).toLowerCase() // also okay
and the compiler would be just as happy to allow it, even though you are going to have a problem at runtime. So be careful!
Playground link to code
item[key]may in fact be anumber? Observe.... The compiler is technically right to complain. Presumably you should either just assume this won't happen and use a type assertion or be extra careful (not sure what you want to do if that's violated) or something else. I'm happy to write these up as an answer; if you still have unmet needs, though, please edit the question to specify them.string | number, but it still needs a type assertion because the compiler cannot really understand thatitem[key]andvalueare correlated to each other; see ms/TS#30581. Let me know if I can write this up as an answer or if you need something else.Hin the call signatures). Does this work for you? If so I'll write it up as an answer. If there are still unsatisfied use cases let me know and I'll try to address them.undefinedout of nowhere) and I'll be happy to write up an answer when I get a chance.