Feathers: Hook typing and find method parsing issue

Created on 7 Dec 2020  ·  10Comments  ·  Source: feathersjs/feathers

I use the Hook type when declaring my hook:

type PushToken = {
  token: string;
  applicationSlug: string;
  createdAt?: Date;
}

const handleCreate: Hook<PushToken> = ({ data, service, result }) => {
  if (!data) {
    return;
  }
  const { token, applicationSlug } = data;

  return service
    .find({ query: { token }, paginate: false })
    .then((tokens) => tokens.length)
  ;
}

Works fine, except for the last then callback.

The tokens.length throw the following typescript error:

Property 'length' does not exist on type 'PushToken | PushToken[] | Paginated<PushToken>'.
  Property 'length' does not exist on type 'PushToken'.ts(2339)

This is caused by the following signature:

find (params?: Params): Promise<T | T[] | Paginated<T>>;

I may have only one instance but also an array or a Paginated instance.

So I tried to force the typing like this:

.then((tokens: PushToken[]) => tokens.length)

But then give me now an error on the function typing itself:

Argument of type '(tokens: PushToken[]) => number' is not assignable to parameter of type '(value: PushToken | PushToken[] | Paginated<PushToken>) => number | PromiseLike<number>'.
  Types of parameters 'tokens' and 'value' are incompatible.
    Type 'PushToken | PushToken[] | Paginated<PushToken>' is not assignable to type 'PushToken[]'.
      Type 'PushToken' is missing the following properties from type 'PushToken[]': length, pop, push, concat, and 26 more.ts(2345)

The only way I have to get rid of this error is to set the any type to my tokens params, but I loose all the interest of typescript then.

Not sure if it's a bug, but I can't find any reliable example for that on the documentation.

Most helpful comment

Yes, this will be included in the v5 refactoring with a pull request incoming shortly.

All 10 comments

Got the same issue with patch:

const handleCreate: Hook<PushTokensData, PushTokens> = (context) => {
  const { service, params, data } = context;
  if (!params.user) {
    throw new Error('This hook must be under authentication.');
  }
  if (!data) {
    return;
  }
  const { token } = data;
  data.userId = params?.user?._id;
  data.pingedAt = new Date();

  return service
    .find({ query: { token }, paginate: false })
    // @see https://github.com/feathersjs/feathers/issues/2147
    .then((resolvedTokens: any) => resolvedTokens.length ? resolvedTokens[0] : undefined)
    .then((resolvedToken: PushTokensData) => {
      if (token) {
        return service.patch(resolvedToken._id, data)
          .then((patchedToken) => {
            context.result = patchedToken;

            return context;
          });
      }
    })
  ;
}

Throwing me this:

src/services/push-tokens/push-tokens.hooks.ts:48:13 - error TS2322: Type 'PushTokensData | PushTokensData[]' is not assignable to type 'PushTokensData | undefined'.
  Type 'PushTokensData[]' is missing the following properties from type 'PushTokensData': _id, token, applicationSlug, userId, pingedAt

48             context.result = patchedToken;
               ~~~~~~~~~~~~~~

Am I missing something? I currently have no solution except putting any everywhere. :-/

The patch case may be manged with a simple Array.isArray usage.

However:

  1. If we patch only one resource, we will always get a object instance, not an array. This is cumbersome for its implementation. We may separate patch onto two methods: patch and patchMultiple with a stricter typing (same for update)
  2. The find case is still more complicated because of the Paginated interference. I am not sure about the solution here.

Any though about this?

I tried some successful things. Here is the story.

First, I created an override of the common service adapter:

// src/adapter.ts
import { NullableId, Paginated, Params } from '@feathersjs/feathers';
import { AdapterService as BaseAdapterService } from '@feathersjs/adapter-commons';

const resultToOne = <T>(value: T | T[]): T => {
  if (Array.isArray(value)) {
    throw new Error('The expected result should not be an array.');
  }

  return value;
}

export class AdapterService<T = any> extends BaseAdapterService<T> {
  createOne(data: Partial<T> | Partial<T>[], params?: Params): Promise<T> {
    return this.create(data, params).then(resultToOne);
  }

  patchOne(id: NullableId, data: Partial<T>, params?: Params): Promise<T> {
    return this.patch(id, data, params).then(resultToOne);
  }

  findPaginated(params?: Params): Promise<Paginated<T>> {
    if (params?.paginate === false) {
      throw new Error('You must not deactivate the pagination on this method.');
    }

    return this.find(params).then((result) => {
      if (Array.isArray(result)) {
        throw new Error('The expected result should be paginated.');
      }

      return result;
    });
  }

  findAll(params?: Params): Promise<T[]> {
    if (typeof params?.paginate !== undefined && params?.paginate !== false) {
      throw new Error('You must not configure the pagination on this method.');
    }

    return this.find({
      ...params,
      paginate: false,
    }).then((result) => {
      if (!Array.isArray(result)) {
        throw new Error('The expected result should not be paginated.');
      }

      return result;
    });
  }
}

Then I use it to declare my application typing:

// src/declarations.d.ts
import { ServiceAddons } from '@feathersjs/feathers';
import { Application as ExpressFeathers } from '@feathersjs/express';
import '@feathersjs/transport-commons';
import { AdapterService } from './src/adapter';

export interface ServiceData {
  _id?: string;
}

export interface UsersData extends ServiceData {
  name: string;
  firstName: string;
  lastName: string;
}

type Service<T> = AdapterService<T> & ServiceAddons<T>;
export interface ServiceTypes {
  'users': Service<UsersData>;
}

And it works perfectly fine allowing me to get the expected result without having the typescripts error I described:

app.service('users').patchOne(
  // ...
).then((myOnlyOneUser) => {
  // ...
});

However, it does not works with the service provided by the hook context:

const myHook: Hook<UsersData> = (context) => {
  const { service } = context;
  // This following method call is not recognized by Typescript.
  service.patchOne();

Any clue about how to solve this would be welcomed.

I may not done it the right way. However, this new methods I created simplify a lot the implementation code complexity, increasing the productivity. Also, it makes sense to me to have this hack placed at this place.

If you agree, we may discuss about a merge request proposal here.

Thanks for reading.

I just found how to get the service typing working properly on a hook context, here is an another sample:

First, I have to export my custom service type in addition to the global definition:

@@ -83,6 +83,8 @@ export interface UsersData extends ServiceData {
 }

 type Service<T> = AdapterService<T> & ServiceAddons<T>;
+
+export type PushTokensService = Service<PushTokensData>;
 export interface ServiceTypes {
   'auth-sms': Service<AuthSmsData>;
   'billing-departments': Service<BillingDepartmentsData>;
@@ -90,7 +92,7 @@ export interface ServiceTypes {
   'credit-cards': Service<any>;
   'notifications': Service<NotificationsData>;
   'postal-codes': Service<any>;
-  'push-tokens': Service<PushTokensData>;
+  'push-tokens': PushTokensService;
   'rides': Service<any>;
   'sms': Service<any>;
   'transactions': Service<any>;

Then import and use the related service:

const handleCreate: Hook<PushTokensData, PushTokensService> = (context) => {
  // Your awesome code here.
}

Update: If you don't want to declare tons of service type, you may directly use the right service that way:

const handleCreate: Hook<PushTokensData, ServiceTypes['push-tokens']> = (context) => {

Update Bis: This is not working properly:

src/services/push-tokens/push-tokens.service.ts:17:17 - error TS2345: Argument of type '{ before: { all: (IffHook | ((context: HookContext<any, Service<any>>) => Promise<HookContext<any, Service<any>>>))[]; ... 5 more ...; remove: never[]; }; after: { ...; }; error: { ...; }; }' is not assignable to parameter of type 'Partial<HooksObject<any>>'.
  Types of property 'before' are incompatible.
    Type '{ all: (IffHook | ((context: HookContext<any, Service<any>>) => Promise<HookContext<any, Service<any>>>))[]; find: IffHook[]; ... 4 more ...; remove: never[]; }' is not assignable to type 'Hook<any, Service<any>> | Hook<any, Service<any>>[] | Partial<HookMap<any>> | undefined'.
      Type '{ all: (IffHook | ((context: HookContext<any, Service<any>>) => Promise<HookContext<any, Service<any>>>))[]; find: IffHook[]; ... 4 more ...; remove: never[]; }' is not assignable to type 'Partial<HookMap<any>>'.
        Types of property 'create' are incompatible.
          Type '(Hook<any, Service<any>> | Hook<PushTokensData, Service<PushTokensData>>)[]' is not assignable to type 'Hook<any, Service<any>> | Hook<any, Service<any>>[] | undefined'.
            Type '(Hook<any, Service<any>> | Hook<PushTokensData, Service<PushTokensData>>)[]' is not assignable to type 'Hook<any, Service<any>>[]'.
              Type 'Hook<any, Service<any>> | Hook<PushTokensData, Service<PushTokensData>>' is not assignable to type 'Hook<any, Service<any>>'.
                Type 'Hook<PushTokensData, Service<PushTokensData>>' is not assignable to type 'Hook<any, Service<any>>'.
                  Types of parameters 'context' and 'context' are incompatible.
                    Type 'HookContext<any, Service<any>>' is not assignable to type 'HookContext<PushTokensData, Service<PushTokensData>>'.
                      Type 'Service<any>' is not assignable to type 'Service<PushTokensData>'.
                        Type 'ServiceOverloads<any> & ServiceAddons<any> & ServiceMethods<any>' is missing the following properties from type 'AdapterService<PushTokensData>': createOne, patchOne, findPaginated, findAll, and 4 more.

17   service.hooks(hooks);
                   ~~~~~

It looks like I am limited with hook because it expects this service type: https://github.com/feathersjs/feathers/blob/58f1ed3659ff4ef6815883ea6366bdca8c0095f6/packages/feathers/src/declarations.ts#L208

However, I don't know how to properly integrate my service override on them.

Finally my setup works only for typing, according to the test:

 FAIL  test/services/transactions.test.ts
  ● transaction creation › creates as an admin

    TypeError: app_1.default.service(...).patchOne is not a function

      73 |   }
      74 |   if (!toUser.mangoPayId) {
    > 75 |     toUser = await app.service('users').patchOne(toUser._id, {
         |                                         ^
      76 |       mangoPaySetup: true,
      77 |     });
      78 |   }

      at Object.handleCreate (src/services/transactions/transactions.hooks.ts:75:41)

I am quite lost here. How can I bring my function to the app? My app.ts file looks limited for this:

const app: Application = express(feathers());

This will be addressed in the upcoming version with a bunch of other (probably breaking) TypeScript changes. Unfortunately there currently is no other option than to force cast it at the moment.

Thanks for the feedback @daffl!

You says it is planned. Do you have an issue to reference? :thinking:

@daffl May this issue being added on the v5 milestone then? https://github.com/feathersjs/feathers/milestone/11

By the way, do you have an already existing issue of this milestone to refer that is related to the exposed problematic?

Thanks!

Yes, this will be included in the v5 refactoring with a pull request incoming shortly.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

huytran0605 picture huytran0605  ·  3Comments

arve0 picture arve0  ·  4Comments

rstegg picture rstegg  ·  3Comments

corymsmith picture corymsmith  ·  4Comments

NetOperatorWibby picture NetOperatorWibby  ·  4Comments