typescripttypescript-typings

How to merge back a key with a type where the key was previously Omitted?


I am trying to implement a generic inMemoryGateway builder. I have a typing problem on the create implementation: I want to be able to give an entity without the 'id' (using typescript Omit) and than to add the missing 'id'. But the types don't seem compatibles. I used as any for now but anyone would see a cleaner solution ?

interface EntityGateway<E extends {id: string}> {
  create: (entity: Omit<E, 'id'>) => E
  getAll: () => E[]
}

const buildInMemoryGateway = <Entity extends {id: string}>(): EntityGateway<Entity> => {
  const entities: Entity[] = [];

  return {
    create: (entityWithoutId: Omit<Entity, 'id'>) => {
      const entity: Entity = { ...entityWithoutId, id: 'someUuid' }
      // Error here on entity : 
      // Type 'Pick<Entity, Exclude<keyof Entity, "id">> & { id: string; }' is not assignable to type 'Entity'.
      // ugly fix: const entity: Entity = { ...entityWithoutId as any, id: 'someUuid' }

      entities.push(entity);
      return entity
    },
    getAll: () => {
      return entities;
    }
  }
}



interface Person {
  id: string,
  firstName: string,
  age: number,
}

const personGateway = buildInMemoryGateway<Person>();

personGateway.create({ age: 35, firstName: 'Paul' });   // OK as expected
personGateway.create({ age: 23, whatever: 'Charlie' }); // error as expected

console.log("Result : ", personGateway.getAll())

Solution

  • The fundamental issue here is the same as in this question about assigning a value to a Partial<T> when T is a generic parameter extending some known object type U. You can't just return a value of type Partial<U>, because when T extends U it could do so by adding new properties to U (no problem), or by narrowing the existing properties of T (uh oh!). And since in a generic function the caller chooses the type parameter, the implementation cannot guarantee that properties of T won't be narrower in type than the corresponding properties of U.

    That leads to this problem:

    interface OnlyAlice { id: "Alice" };
    const g = buildInMemoryGateway<OnlyAlice>();
    g.create({});
    g.getAll()[0].id // "Alice" at compile time, "someUuid" at runtime.  Uh oh!
    

    If you wanted to rewrite your code safely, you could do so by making the code less readable and more complex, by keeping the actual type you've created: not E, but Omit<E, "id"> & {id: string}. That is always true, even if the original E has a narrower type for its id property:

    type Stripped<E> = Omit<E, "id">;
    type Entity<E> = Stripped<E> & { id: string };
    
    interface EntityGateway<E> {
        create: (entity: Stripped<E>) => Entity<E>
        getAll: () => Entity<E>[]
    }
    
    const buildInMemoryGateway = <E>(): EntityGateway<E> => {
        const entities: Entity<E>[] = [];
        return {
            create: (entityWithoutId: Stripped<E>) => {
                const entity = { ...entityWithoutId, id: 'someUuid' }
                entities.push(entity);
                return entity
            },
            getAll: () => {
                return entities;
            }
        }
    }
    

    And that behaves the same for your examples:

    interface Person {
        id: string,
        firstName: string,
        age: number,
    }
    
    const personGateway = buildInMemoryGateway<Person>();
    
    personGateway.create({ age: 35, firstName: 'Paul' });   // OK as expected
    personGateway.create({ age: 23, whatever: 'Charlie' }); // error as expected
    

    But now it behaves differently for the pathological example above:

    interface OnlyAlice { id: "Alice" };
    const g = buildInMemoryGateway<OnlyAlice>();
    g.create({});
    g.getAll()[0].id // string at compile time, "someUuid" at run time, okay!
    

    If you read that and said to yourself, "oh come on, nobody's going to narrow the id property to a string literal", that's fair. But it means you need to use something like a type assertion, as you saw:

     const entity = { ...entityWithoutId, id: 'someUuid' } as E; // assert
    

    You might expect that the compiler could see this as acceptable:

     const entity: E = { ...entityWithoutId, id: 'someUuid' as E["string"]}; // error!
    

    but that doesn't work because the compiler doesn't really bother trying to analyze the intersection of an unresolved conditional type like Omit<E, "id">. There's a suggestion at microsoft/TypeScript#28884 to address that but for now you need a type assertion.


    Anyway I'd expect the way you want to go here is to use a type assertion, but hopefully the explanation above shows what the compiler is doing.

    Link to code