0

Let's say that I have the following entity model:

interface Entity {
  id: string;
}

interface Member extends Entity {
  group: Group | string;
}

interface Group extends Entity {
  members: (Member | string)[];
}

I will be separately retrieving lists of both Group and Member entities. At retrieval, the entities will contain string references to other entities. E.g., a Member entity will have a group property containing the ID of the group that the member belongs to.

Now, after retrieval, I want to hydrate/inflate the entities by replacing the string references with the entities that they refer to. E.g., the following call should replace the group references in Member entities with the actual Group objects:

link(members, groups, 'group');

Now, I want to restrict the third parameter in that call ('group') to only allow property names that can potentially refer to a Group entity, so I write the following type definition:

type Ref<T, S> = { [K in keyof T]: S extends T[K] ? K : never }[keyof T];

This works correctly, e.g. type X = Ref<Member, Group> will evaluate to group.

The method implementation could then look like this:

function link<T extends Entity, S extends Entity>(target: T[], source: S[], ref: Ref<T, S>) { 
  const lookup = new Map(source.map<[string, S]>(entity => [entity.id, entity]));

  target.forEach(entity => {
    const value = entity[ref];
    entity[ref] = typeof value === 'string' ? lookup.get(value) || value : value; // error
  });
}

Unfortunately, this throws compiler errors:

Type 'S | T[{ [K in keyof T]: S extends T[K] ? K : never; }[keyof T]]' is not assignable to type 'T[{ [K in keyof T]: S extends T[K] ? K : never; }[keyof T]]'.
  Type 'S' is not assignable to type 'T[{ [K in keyof T]: S extends T[K] ? K : never; }[keyof T]]'.
    Type 'Entity' is not assignable to type 'T[{ [K in keyof T]: S extends T[K] ? K : never; }[keyof T]]'.(2322)

I believe that, with TypeScript's rich type system, it should be perfectly possible to come up with an elegant solution, but I can't seem to get it right. What am I missing here?

Here's a link to the TypeScript Playground for those who want to take a stab at it.


Note that I'm looking for an elegant ideomatic TypeScript solution, and not for a hack to coerce the compiler. If I wanted that, I might as well write plain JavaScript.

2 Answers 2

1

Generally when you have conditional and mapped types in a generic function in the implementation TS will have a hard time following all the implications of those types all the way to the end. Generally a conditional or mapped type that still contains undresolved type parameters will only be assignable to itself, so S will not be assignable to T[Ref<T, S>].

There is a way to make the implementation of the function type safe, but it comes as the cost of developer experience:

function link<S extends Entity, K extends PropertyKey>(target: Array<Record<K, string | S>>, source: S[], ref: K) { 
  const lookup = new Map(source.map<[string, S]>(entity => [entity.id, entity]));

  target.forEach(entity => {
    const value = entity[ref];
    entity[ref] = typeof value === 'string' ? lookup.get(value) || value : value;
  });
}

Playground Link

Simplifying ref to be a simple type parameter and making clear that an item of target has a value of type S | string for key K will help the compiler check the function. The downsides of the approach are:

  1. You will not get code completions for the ref parameter, after all the compiler only knows it's a key, not the key of what object it is. You will get errors on the target parameter if the key is not part of the object or does not have type S | string

  2. You lose the ability to pass in object literals (because of excess property checks). The compiler will complain that for link([{group: "", a: ""}], groups, 'group'); a is not a known property.

Given the caveats above, I would actually stick with your version, and use a type assertion.

Or you could use a separate implementation signature, but don't expect that TS is very strict about the two signatures actually boiling down to the exact same thing (it does some checks but they are quite lose)

function link<T extends Entity, S extends Entity>(target: T[], source: S[], ref: Ref<T, S>): void
function link<S extends Entity, K extends PropertyKey>(target: Array<Record<K, string | S>>, source: S[], ref: K) { 
  const lookup = new Map(source.map<[string, S]>(entity => [entity.id, entity]));

  target.forEach(entity => {
    const value = entity[ref];
    entity[ref] = typeof value === 'string' ? lookup.get(value) || value : value;
  });
}

Playground Link

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

Comments

1

Change the signature to:

function link<T extends Entity, K extends keyof T, S extends T[K] & Entity>(
  target: T[], source: S[], ref: K
) {

TypeScript isn't able to infer a relationship as complex as S extending T[{ [K in keyof T]: S extends T[K] ? K : never; }[keyof T]].

Comments

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.