apollographql / apollo-feature-requests

🧑‍🚀 Apollo Client Feature Requests | (no 🐛 please).
Other
130 stars 7 forks source link

Returning class-typed (with methods) from apollo query responses for objects? #364

Open dko-slapdash opened 3 years ago

dko-slapdash commented 3 years ago

Hi.

Is there a way to tell that some part of apollo query response is not a plain JS object, but some reach object with extra methods?

Imagine we have the following query:

query {
  viewer {
    org {
      id
      domain
    }
  }
}

And imagine we have the following function:

function normalizeDomain(domain: string) {}

Every time we call such a query, when working with domain field, we must not forget to call normaliseDomain() for it - which is tough: the function itself is logically separated from the data object. Especially when there is already a TS interface OrgFragment with these fields.

When running e.g. useQuery() or client.query(), the returned response's data is a plain JS object. It would be cool if we could tell apollo client that some parts of the response are actually "reach objects" with methods. Then, instead of normalizeDomain(res.data.viewer.org.domain) I could've run just res.data.viewer.org.domainNormalized() and even have the original domain field hidden somehow.

I want it to exist on client side, not server side (because the example above is short just for simplicity; in real life, the objects may be way more complicated).

dko-slapdash commented 3 years ago

One more idea - it would actually be cool to be able to attach methods to fragments and then have these methods automatically available on all the returned objects which were generated by this fragment. This way the method would be 100% sure that the needed fields are there.

benjamn commented 3 years ago

@dko-slapdash This is a very interesting idea, especially in conjunction with https://github.com/apollographql/apollo-client/issues/4141#issuecomment-733091694, which I believe will provide a convenient place in the cache reading process to create the custom wrapper objects you're talking about (which inherit from some custom prototype).

From a configuration perspective, this should be just a matter of specifying a custom constructor for a given __typename in typePolicies (note: not implemented yet):

class Org extends CacheResultEntity {
  normalizeDomain() {
    return normalize(this.get("domain"));
  }
}

const cache = new InMemoryCache({
  typePolicies: {
    Org: {
      class: Org,
    },
  },
})

If this API was available, every time your application reads an object with __typename: "Org" as part of cache.readQuery or cache.readFragment, you'd get an object of type Org, inheriting from Org.prototype. These objects would probably need to be deeply frozen to remain safely reusable, but your use case (normalizeDomain) seems compatible with immutability.

This API should also work well with https://github.com/apollographql/apollo-client/pull/7065, so you can associate a class: with some shared supertype, rather than having to define class: TheClass for every subtype in typePolicies.

Mootook commented 3 years ago

This would be great (not sure if the discussion has progressed elsewhere), but wrapper classes would help my use case a lot. Also, tried the following which did not work (each item in array returns to Object):

typePolicies: {
  List: {
    fields: {
      items: {
       read (items) {
        return items.map(i => new Item(i))
      }
    } 
  }
}

though I think the possible API outline above is a much nicer solution.

martdavidson commented 2 years ago

I'm actually surprised at how little interest and writing there seems to be on having queries / mutations return domain objects vs the POJO returned by the API.

The solutions I can think of at the moment are not great. You either manually cast everywhere you call a hook which is annoying, and gets recreated on each render, so obviously bad:

const userQuery = useGetUser();

const user = UserDomainObject.create(userQuery.data?.user);

Or you can create a custom hook that wraps the one generated by graphql-code-generator (assuming you're using that) for every hook that does that transformation for you:

import {useGetUser as useGeneratedGetUser} from '/src/generated/graphql.ts';

const useGetUser = () => {
  const getUserQuery = useGeneratedGetUser();

  return {
    ...getUserQuery,
    data: {user: UserDomainObject.create(getUserQuery.data?.user)}
  }

And add some extra magic for memoization so that the domain object only gets recreated if the data has actually changed.

Or you ignore hooks altogether and write services for every call using client.query or whatever you want, which at the point you don't even need the generated hooks since you're doing it all by hand anyway.

But in all solutions I've explored so far it requires a lot of boilerplate and lifecycle finagling instead of being able to conveniently tell apollo client per-query to return a specific instance of a class instead of just whatever is returned by the API.

IMO, this encourages operating directly on the returned shape from the backend APIs throughout your application code, which on larger projects opens you up to a world of hurt as the API may change. Even though you get nice types through inspection and graphql-code-generator so it's easy to track down, it still means a lot of code changing instead of having a nice layer of inderection between data returned from the API and consumed by your application.

Would love to hear what other people are doing that discover this ticket (it seems to be only the one discussing this kind of issue that I can find).

Edit:

Thinking more about @benjamn comment, I don't like the idea of:

typePolicies: {
    Org: {
      class: Org,
    },
  },

In our app we'll often query different fields of the same entity in different places, keeping our queries as small as possible as needed. So we would really need instance per query, not instance per __typename.