0

With the following classes:

abstract class ModelBase {
  id: string;
}

class Person extends ModelBase {
  favoriteDog: Dog | undefined;
  favoriteDogId: string | undefined;
  dogs: Dog[]
}

class Dog extends ModelBase {
  id: string;
  ownerId: string;
  name: string;
}

If have an array of Persons and an Array of Dogs, I'd like to map them using a method like:

const persons = [{ id: 'A', favoriteDog: undefined, favoriteDogId: 'B'}];
const dogs = [{ id: 'B', name: 'Sparky'}];

mapSingle(persons, "favoriteDog", "favoriteDogId", dogs);

console.log(persons[0].favoriteDog?.name); // logs: Sparky

I have the following code:

  static mapSingle<TEntity extends ModelBase , TDestProperty extends keyof TEntity, TDestPropertyType extends (TEntity[TDestProperty] | undefined)>(
    destinations: TEntity[],
    destinationProperty: keyof TEntity,
    identityProperty: keyof TEntity,
    sources: TDestPropertyType[]) {

    destinations.forEach(dest => {
      const source = sources.find(x => x["id"] == dest[identityProperty]);
      dest[destinationProperty] = source;  // <--- Error Line
    });
  }

Error:

TS2322: Type 'TDestPropertyType | undefined' is not assignable to type 'TEntity[keyof TEntity]'

Type 'undefined' is not assignable to type 'TEntity[keyof TEntity]'.

I get the error message, I'm having trouble with the language to specify that the property can be (maybe the compiler can even check that it should be) undefine-able.

Eventually I would create a similar method, using similar tactics;

mapMany(persons, 'Dogs', 'OwnerId', dogs);

Related Reading:

In TypeScript, how to get the keys of an object type whose values are of a given type?

Typescript Playground Example

Erik Philips
  • 53,428
  • 11
  • 128
  • 150
  • 1
    This isn't a complete example (classes are lacking initialisers and BaseModels.Entity doesn't seem to relate to the quoted ModelBase). Please create a working example in something like https://www.typescriptlang.org/play and link to it here. – Dave Meehan May 01 '22 at 15:27
  • @DaveMeehan Link at the bottom. – Erik Philips May 01 '22 at 20:11

2 Answers2

1

First up, a non-generic version which illustrates the problem:

abstract class ModelBase {
  id: string;
}

class Person extends ModelBase {
  favoriteDog: Dog | undefined;
  favoriteDogId: string | undefined;
  dogs: Dog[]
}

class Dog extends ModelBase {
  id: string;
  ownerId: string;
  name: string;
}

function mapSingle(persons: Person[], instanceKey: keyof Person, idKey: keyof Person, dogs: Dog[]) {
  persons.forEach(person => {
    const dog = dogs.find(dog => dog['id'] == person[idKey])
    person[instanceKey] = dog
//  ^^^^^^^^^^^^^^^^^^^
//  (parameter) instanceKey: keyof Person
//    Type 'Dog | undefined' is not assignable to type 'Dog & string & Dog[]'.
//      Type 'undefined' is not assignable to type 'Dog & string & Dog[]'.
//        Type 'undefined' is not assignable to type 'Dog'.(2322)
  })
}

What is perplexing about this is why TS thinks that person[instanceKey] is of type Dog & string & Dog[].

If you look at the docs for keyof, you'll realise that there are two potential results, one being the names of the keys, and the other being the types of the keys.

Yes this appears to show a merging of the types (&).

This lead me to realise that instanceKey is actually meant to be Dog | undefined, if we are going to assign Dog or undefined (the result of find) to it.

I searched for a way of getting only the keys of a certain type, which gave me KeysOfType.

type KeysOfType<T, TProp> = { [P in keyof T]: T[P] extends TProp? P : never}[keyof T];

I also reworked the types into interfaces:

interface Identifiable {
  id: string;
}

interface DogOwning {
  favoriteDog: Dog | undefined;
  favoriteDogId: string | undefined;
  dogs: Dog[]
}

interface Person extends Identifiable, DogOwning {
}

interface Dog extends Identifiable {
  ownerId: string;
  name: string;
}

And that leads us to this, which now accepts the dog assignment:

function mapSingle(
  owners: Person[], 
  assignedKey: KeysOfType<DogOwning, Dog | undefined>, 
  foreignKey: KeysOfType<DogOwning, string | undefined>, 
  ownable: Dog[]
  ) {
  
  owners.forEach(owner => {
    owner[assignedKey] = ownable.find(entity => entity['id'] == owner[foreignKey])
    
  })
}

Unfortunately, it doesn't seem to be possible to make that generic, once we start passing in generics for Person, DogOwning and Dog, we get back to a similar problem. I don't know if TS has a limit on nesting of generics, but that seems to be what happens.

I'm a bit worried here that because the assignedKey and foreignKey and merely validated as properties in the owner object type, and of the expected type, it doesn't ensure they are the correct keys. I think that's also the case with direct property assignment so may not be a concern. There are likely other ways that (methods on Person, or in a Coordinator class), that would allow you to isolate that code instead of scattering around where its used, which might lead to bugs. The alternate answer I gave was leaning in that direction.

Dave Meehan
  • 3,133
  • 1
  • 17
  • 24
0

Alternative Approach

I came to realise that there was another way of solving the model problem (potentially) but appreciate that the example might be simplified and go beyond a simplification of the solution.

In essense, Person.favouriteDog is a getter, rather than requiring a specific Dog to be copied (albeit by reference) to the Person.

Here is a minimal alternative, although it will need expanding with utility methods on Person and Dog to ensure referential integrity (or perhaps a Coordinator instance that deals with assigning Dogs to Persons)

interface Identifiable {
  id: string
}

interface DogOwning {
  favoriteDog?: Dog
  favoriteDogId?: string
  dogs: Dog[]
}

class Person implements Identifiable, DogOwning {
  constructor(public id: string) {}
  favoriteDogId?: string
  get favoriteDog(): Dog | undefined {
    if (!this.favoriteDogId) {
      return undefined
    }

    const favorite = this.dogs.find(dog => dog.id === this.favoriteDogId)
    if (favorite) {
      return favorite
    }
    throw "Referential Integrity Failure: favoriteDogId must be found in Persons dogs"
  }
  public readonly dogs: Dog[] = []
}

class Dog implements Identifiable {
  public ownerId?: string
  constructor(public id: string, public name: string) {}
}

const personA = new Person('A')
console.log(`favoriteDog is ${personA.favoriteDog?.name}`)

const sparky = new Dog('B', 'Sparky')
personA.dogs.push(sparky)
personA.favoriteDogId = sparky.id

console.log(`favoriteDog is ${personA.favoriteDog?.name}`)

personA.dogs.splice(personA.dogs.findIndex(dog => dog.id === sparky.id), 1)

console.log(`favoriteDog is ${personA.favoriteDog?.name}`)

Playground Link

Dave Meehan
  • 3,133
  • 1
  • 17
  • 24
  • That is definitely the way to do it if the object has a list of all dogs. For network sake, I don't always download all dogs of all people. – Erik Philips May 02 '22 at 23:52
  • You don't need all dogs of all people, but one assumes that `Person.dogs` is all dogs belonging to that Person. As you had the property in your example but weren't using it, its just a guess. – Dave Meehan May 03 '22 at 06:59
  • yeah that was for the second part of mapping all dogs to specific owners. – Erik Philips May 03 '22 at 07:05