1

So I have these types:

type car = {
    id: number;
    horsePower?: number;
    date: Date;
};

type book = {
    id: string;
    title?: string;
    date: Date;
};

Now I want to create a generic function that takes a parameter like horsePower or title and searches in a list of cars or books for duplicates. So the search parameter and the id can differ in types, so string|number is possible for both.

But the passed in key and value needs to be of the same type, either string or number.

I tried this approach:

constructor() {
  const carList: Array<car> = [{ id: 111, horsePower: 1337 }];
  this.ifAlreadyExists(carList, 'horsePower', 1337, 'id', 100);

  const bookList: Array<book> = [{ id: '222', title: 'Title 1' }];
  this.ifAlreadyExists(bookList, 'title', 'Title 1', 'id', '200');
}

ifAlreadyExists<T, H extends keyof T, K extends keyof T>(
  data: Array<T>,
  key: Extract<keyof T, string | number>,
  value: Extract<T[K], string | number>,
  idKey: H,
  idValue: T[H]
): boolean {
  if (typeof value === 'string' && typeof key === 'string') {
    return (
      data.filter(
        (item) =>
          item[idKey] !== idValue &&
          item[key] &&
          value &&
          item[key].trim().toLowerCase() ===
          value.trim().toLowerCase()
      ).length === 0
    );
  } else {
    return (
      data.filter(
        (item) =>
          item[idKey] !== idValue &&
          item[key] &&
          value &&
          item[key] === value
      ).length === 0
    );
  }
}

Repro: https://codesandbox.io/s/angular-11-playground-forked-y9ku0?file=/src/app/app.component.ts

But when I want to do type specific calls, it fails:

Property 'trim' does not exist on type 'T[Extract<keyof T, string | number> & string]'.ts(2339)

How can I tell typescript, that the passed key matches the type of the passed value. So then item[key] could only be of the types string or number

14
  • 1
    Please replace/supplement images of code/errors with plaintext versions. Commented Nov 24, 2021 at 19:48
  • 1
    You may be surprised to realize that item[key] may in fact be a number? 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. Commented Nov 24, 2021 at 19:53
  • 1
    Well does this meet your needs? This prevents someone from calling the function with string | number, but it still needs a type assertion because the compiler cannot really understand that item[key] and value are 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. Commented Nov 26, 2021 at 2:01
  • 1
    Oops, that was something like a typo in my code (I forgot to use H in 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. Commented Nov 26, 2021 at 15:56
  • 1
    Like this? Just make sure to update the example code in the question (so that the answer doesn't look like it's introducing that undefined out of nowhere) and I'll be happy to write up an answer when I get a chance. Commented Dec 2, 2021 at 15:12

3 Answers 3

2
+100

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

Sign up to request clarification or add additional context in comments.

Comments

1

You could use a tuple to store both the key and value arguments.

type car = {
    id: number;
    horsePower?: number;
    date: Date;
};

type book = {
    id: string;
    title?: string;
    date: Date;
};

function foo([key, value]: ["horsePower", number] | ["title", string]) {

    if (key === "horsePower") {
        console.log(value); // will always be a number
    }

    if (key === "title") {
        console.log(value); // will always be a string
    }

}

Comments

0

If you want to only run it you may:

data.filter(
#here-->  (item:any) =>
          item[idKey] !== idValue &&
          item[key] &&
          value &&
          item[key].trim().toLowerCase() ===
          value.trim().toLowerCase()
      ).length === 0

but I don't think that 'any' is the best solution

1 Comment

That does remove the error shown in the IDE but at runtime it could happen that item[key] is of the type number and then an error gets thrown. item[key].trim() is not possible on a number

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.