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?
CodePudding user response:
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 Dog
s to Person
s)
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}`)
CodePudding user response:
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];
From that, we can pick out keys that accepts Dog | undefined
, and string | undefined
.
type DogKeys = KeysOfType<Person, Dog | undefined>
type IdKeys = KeysOfType<Person, string | undefined>
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[], dogKey: DogKeys, idKey: IdKeys, dogs: Dog[]) {
owners.forEach(person => {
const dog = dogs.find(dog => dog['id'] == person[idKey])
person[dogKey] = dog
})
}
Sadly, this seems to start to fall down when we introduce generics (and I think the OP wants a method that will operate on relationships between different entities).
type DogKeys<T> = KeysOfType<T, Dog | undefined>
type IdKeys<T> = KeysOfType<T, string | undefined>
function mapSingle<Owner extends Identifiable & DogOwning>(owners: Owner[], dogKey: DogKeys<Owner>, idKey: IdKeys<Owner>, dogs: Dog[]) {
owners.forEach(person => {
const dog = dogs.find(dog => dog['id'] == person[idKey])
// ^^^^^^^^^^^^^^^^^^^^^^^^^^
// This condition will always return 'false' since the types 'string' and 'Owner[IdKeys<Owner>]' have no overlap.(2367)
person[dogKey] = dog
// ^^^^^^^^^^^^^^^^^^^^
// Type 'Dog | undefined' is not assignable to type 'Owner[DogKeys<Owner>]'.
// Type 'undefined' is not assignable to type 'Owner[DogKeys<Owner>]'.(2322)
})
}
For some reason the type system seems to not be able to convey the necessary information through an extra level of indirection.
I then realised that if Owner
conforms, we don't need to use Owner
, but can get the required keys from DogOwning
and Identifiable
. That removes a layer of indirection and seems to work.
function mapSingle<Owner extends Identifiable & DogOwning>(owners: Owner[], dogKey: DogKeys<DogOwning>, idKey: IdKeys<Identifiable>, dogs: Dog[]) {
owners.forEach(person => {
const dog = dogs.find(dog => dog['id'] == person[idKey])
person[dogKey] = dog
})
}
Seems, being the operative word. person[idKey]
is restricted to id
which happens to work, but it should actually include favoriteDogId
.
THat's it for now, I'll try and come back for more later.