gabrielguerrero / ngrx-traits

NGRX Traits is a library to help you compose and reuse state logic in your angular app. There is two versions, @ngrx-traits/signals supports ngrx-signals, and @ngrx-traits/{core, common} supports ngrx.
MIT License
44 stars 3 forks source link

not able to create custom store features with generic that use withEntities #92

Open gabrielguerrero opened 1 month ago

gabrielguerrero commented 1 month ago

Currently we can not create a custom store features with generics that use entity and collection pass to the withEntities* for example

export function withEntityMethods<
    Entity extends { id: string | number },
    Collection extends string ,
>(
    entity: Entity,
    collection: Collection,
    fetchEntities: () => Observable<{ entities: Entity[]; total: number }>,
) {
  return signalStoreFeature(
      withEntities({ entity, collection }),
      withCallStatus({ initialValue: 'loading', collection: collection }),
      withEntitiesLocalPagination({ pageSize: 10, entity, collection }),
      withEntitiesLoadingCall({ collection, fetchEntities }),
      withStateLogger({ name: collection })
  );
}

We get the following error

Type 'Omit<NamedEntitySignals<Entity, Collection>, FeatureResultKeys<{ state: NamedCallStatusState<Collection>; signals: NamedCallStatusComputed<...>; methods: NamedCallStatusMethods<...>; }>> & { [K in Collection as `is${Capitalize<string & K>}Loading`]: Signal<...>; } & { [K in Collection as `is${Capitalize<string & K>}...' is not assignable to type 'NamedEntitySignals<Entity, Collection>'.

After a lot of investigation this is cause by the way the types are merged in ngrx-signals

type MergeTwoFeatureResults<First extends SignalStoreFeatureResult, Second extends SignalStoreFeatureResult> = {
    state: Omit<First['state'], FeatureResultKeys<Second>>;
    signals: Omit<First['signals'], FeatureResultKeys<Second>>;
    methods: Omit<First['methods'], FeatureResultKeys<Second>>;
} & Second;

This is use to combine to SignalStoreFeatures and if First and Second have the same props the Second will override the First;

The following could fix the issue but it has the problem that if there is two signalstore with the same prop types, the generate prop will have both types the First and Second in an or

type MergeTwoFeatureResults<First extends SignalStoreFeatureResult, Second extends SignalStoreFeatureResult> = First & Second; 

The way is currently done doesnt work I beleive because of the following bug in typescript https://github.com/Microsoft/TypeScript/issues/28884#issuecomment-448356158

The basic problem is if we have two types First and Second are merge them using the following to override props

type Combine<First,Second> = Omit<First>, keyof Second>& Second;

Then you can not cast the result to First even though it should work

Small duplication of the problem


export type EntityState<Entity> = {
    entityMap: Record<string | number, Entity>;
    ids: string | number[];
};
export type NamedEntityState<Entity, Collection extends string> = {
    [K in keyof EntityState<Entity> as `${Collection}${Capitalize<K>}`]: EntityState<Entity>[K];
};
export type NamedCallStatusState<Prop extends string> = {
    [K in Prop as `${K}CallStatus`]: 'init' | 'loading' | 'loaded';
};

export function withEntityMethods<
  Entity extends { id: string | number },
  const Collection extends string,
>(
  entity: Entity,
  collection: Collection) {

  type Combine =
    Omit<NamedEntityState<Entity, Collection>, keyof NamedCallStatusState<Collection>>
    & NamedCallStatusState<Collection>;

 // fails with: Type 'Combine' is not assignable to type 'NamedEntityState<Entity, Collection>'.
  let y: NamedEntityState<Entity, Collection> = {} as unknown as Combine;
 // workaround use any
 let y2: NamedEntityState<Entity, any> = {} as unknown as Combine;

  // next works
  type Combine2 =
    Omit<NamedEntityState<Entity, 'apps'>, keyof NamedCallStatusState<'apps'>>
    & NamedCallStatusState<'apps'>

The only way I manage to work around this is using // @ts-ignore and a any

 export function withEntityMethods<
    Entity extends { id: string | number },
    Collection extends string ,
>(
    entity: Entity,
    collection: Collection,
    fetchEntities: () => Observable<{ entities: Entity[]; total: number }>,
) {
  // @ts-ignore
  return signalStoreFeature(
      withEntities({ entity, collection }),
      withCallStatus({ initialValue: 'loading', collection: collection }),
      withEntitiesLocalPagination({ pageSize: 10, entity, collection }),
      withEntitiesLoadingCall({ collection, fetchEntities: fetchEntities as any }),
      withStateLogger({ name: collection })
  );
}

There is another way is to use more any of the in the types withEntities* like

export function withEntitiesLocalPagination<
  Entity extends { id: string | number },
  Collection extends string,
>(config: {
  pageSize?: number;
  currentPage?: number;
  entity: Entity;
  collection?: Collection;
}): SignalStoreFeature<
  {
    state: NamedEntityState<Entity, any>; 
    signals: NamedEntitySignals<Entity, any>;  // <--
    methods: {};
  },
  {
    state: NamedEntitiesPaginationLocalState<Collection>;
    signals: NamedEntitiesPaginationLocalComputed<Entity, Collection>;
    methods: NamedEntitiesPaginationLocalMethods<Collection>;
  }
>;

But this will affect the other normal cases , because it will not error when a trait has a dependency missing like withEntitiesLocalPagination will not do a compile error if withEntities is not there , so we will depend on throwing runtime errors to indicate if a dependency is missing but I dont want to go that route, because devs could introduce errors that are only visible when running the app.

I will keep investigating options