What’s New in Apollo Client 4.3
Jerel Miller
We’re excited to announce the release of Apollo Client 4.3. This release brings TypeScript improvements that make working with your cache and incremental data safer, as well as support for our most requested feature to date: custom scalars
Let’s dive in!
TypeScript improvements
Type-safe cache type
4.3 gives you a type-safe way to use caches that offer more than the base ApolloCache interface. Now you can reach for those extra APIs directly, without casting types every time.
Apollo Client allows you to use whatever cache implementation you want, not just InMemoryCache. This is possible because caches use the ApolloCache interface to provide the functionality needed to work with the client.
Some caches include additional features and APIs not available on ApolloCache. It’s difficult to use those features in your app because the cache is typed as ApolloCache anywhere you access the cache from the client. This forces you to type cast the cache to the runtime type when you need access to those features.
4.3 gives you a type-safe way to declare the cache type so that accessing the cache uses your cache instance. Extend the TypeOverrides interface and declare your cache type using the cache key.
1import type { InMemoryCache } from "@apollo/client";23declare module "@apollo/client" {4 interface TypeOverrides {5 cache: InMemoryCache;6 }7}Now anywhere you access cache from Apollo Client it is typed according to the override:
1client.cache 2// ^? InMemoryCache34client.mutate({5 // ...6 update(cache) {7 // ^? InMemoryCache8 }9})GraphQL Codegen incremental types
When using @defer, GraphQL codegen outputs types that include conditional branches to account for holes in the data. For example, the following query defers the name field:
1 query DeferredQuery {2 user {3 id4 ... @defer {5 name6 }7 }8 }GraphQL Codegen outputs the query type using a union where one branch marks each field as an optional never to ensure the field is checked for its presence before its used:
1type DeferredQuery = {2 user:3 | ({ __typename: "User"; id: string } & (4 | { __typename: "User"; name: string }5 | { __typename: "User"; name?: never }6 ))7 | null;8};Apollo Client 4.0 introduced the dataState property to provide type narrowing for the data property to distinguish between the completeness of the data.
Until 4.3, the streaming and complete types were the same, so checking dataState did not remove the need to check whether the fields were present. 4.3 now assembles the type when dataState is complete so that the conditional branches are removed. Extend the GraphQLCodegenIncremental.TypeOverrides interface.
1import type { GraphQLCodegenIncremental } from "@apollo/client/incremental";23declare module "@apollo/client" {4 interface TypeOverrides extends GraphQLCodegenIncremental.TypeOverrides {}5}Custom scalars
With over 465+ reactions and hundreds of comments, custom scalars was by far the most requested feature. Many have been asking for an easier way to convert JSON serialized values into friendlier native objects.
Prior versions gave you the ability to use parsed types by implementing field policy read functions, but this didn’t scale well because it required a lot of manual work to add the read function to every field declared as that scalar type.
4.3 gives you a more complete integration of custom scalars throughout the client. You implement the scalar parsing and serializing logic in one place. This is used to both parse scalar fields into the parsed type, but also allows you to use parsed types in areas you couldn’t before, such as variables and cache writes.
1import { InMemoryCache, Scalar } from "@apollo/client";23const dateTimeScalar = new Scalar<string, Date>({4 // Transforms the raw serialized value into the parsed value5 parse: (dateString) => new Date(dateString),6 // Transforms the parsed value back into its serialized value7 serialize: (date) => date.toISOString(),8 // Predicate function that determines whether the value is parsed or not9 is: (dateOrDateString) => dateOrDateString instanceof Date,10});1112new InMemoryCache({13 scalars: {14 DateTime: dateTimeScalar,15 URL: urlScalar,16 // etc17 },18});This registers the scalar so that it can be used to parse or serialize scalar values as needed. To configure a field as a specific scalar type, use the new scalar option in a field policy so that Apollo Client parses the value for you. Apollo Client automatically parses that field into the published type for you.
1new InMemoryCache({2 // ...3 typePolicies: {4 Post: {5 fields: {6 publishedAt: {7 scalar: "DateTime"8 }9 }10 }11 }12});1314const { data } = useQuery(QUERY)15// => {16// post: { 17// __typename: "Post", 18// id: "1", 19// publishedAt: Date 20// }21// }Custom scalars also serializes scalar values for you and that enables you to use parsed types as input for variables.
1const QUERY = gql`2 query EventsInRange($from: DateTime!, $to: DateTime!) {3 events(from: $from, to: $to) {4 id5 name6 }7 }8`;910const { data } = useQuery(QUERY, {11 variables: { 12 from: new Date("2026-01-01T00:00:000Z"), 13 to: new Date("2026-02-01T00:00:000Z") 14 }15});For more complex input objects, configure their shape with the inputObjects option:
1new InMemoryCache({2 // ...3 inputObjects: {4 DateRange: {5 start: "DateTime",6 end: "DateTime",7 },8 },9});1011const QUERY = gql`12 query EventsInRange($dateRange: DateRange!) {13 events(range: $dateRange) {14 id15 name16 }17 }18`;1920useQuery(QUERY, {21 variables: { 22 dateRange: {23 start: new Date("2026-01-01T00:00:000Z"), 24 end: new Date("2026-02-01T00:00:000Z") 25 }26 }27});Of course manually defining inputObjects and field policies is cumbersome and error prone. Instead, you can generate these objects to ensure they are always up-to-date with the new custom-scalars plugin provided by the @apollo/client-graphql-codegen package:
1// codegen.ts2const config: CodegenConfig = {3 // ...4 generates: {5 "./path/to/custom-scalars.ts": {6 plugins: ["@apollo/client-graphql-codegen/custom-scalars"],7 },8 },9};Then import the generated objects from your generated file:
1import { inputObjects, scalarTypePolicies } from "./path/to/custom-scalars.ts";23const cache = new InMemoryCache({4 inputObjects,5});67cache.policies.addTypePolicies(scalarTypePolicies);For more information, read the custom scalars documentation.
A deeper dive
Much of the work that went into this feature wasn’t actually about custom scalars. In fact, this release included 44 pull requests that added functionality for this release which is quite significant for a handful of features and a relatively small change to the end-user surface area.
During implementation, we encountered two significant hurdles while integrating custom scalars with the rest of the library that required us to really think about how to shape this release. These were integrating with the @defer and @stream directives and reworking how Apollo Client resolves cache feuds.
@defer and @stream
While integrating custom scalars with @defer and @stream, we noticed some test assertions that failed when checking if intermediate values emitted during in-flight @defer chunks returned the parsed values on custom scalar fields (in other words, we want a DateTime string to be parsed into a Date object and returned by the query). For custom scalars to work correctly, it was imperative that these intermediate values returned parsed types, otherwise you get a value that’s sometimes returned as one type, but then switched to another.
This started a rather large side quest to determine how best to address this issue. The issue came down to a single conditional that avoided merging cache data with the network result (reference):
1const diff = cache.diff<TData>(diffOptions);23// If we're allowed to write to the cache, and we can read a4// complete result from the cache, update result.data to be the5// result from the cache, rather than the raw network result.6// Set without setDiff to avoid triggering a notify call, since7// we have other ways of notifying for this result.8if (diff.complete) {9 result = { ...result, data: diff.result };10}diff is the object that contains the cache data for the in-flight query which may contain a partial result and complete tells us whether the diff result fully satisfies the query. We only merge the two when the diff is complete because it ensures we don’t accidentally return a partial result for a query that doesn’t tolerate partial results (e.g. when returnPartialData is false).
The problem is that the diff held the needed parsed scalar values but in-flight @defer queries are partial by nature so these values were not applied to the end result. For those of you that use field policy read functions and happened to notice a similar issue where intermediate incremental results didn’t return the values returned by those functions, this is your root cause. So we needed to seek out a solution that solved both issues at once.
We determined that what we needed was a @defer-aware cache read so that we could safely apply values from the cache to the network result, even if some @defer fragments hadn’t been delivered by the network, to ensure we were returning parsed custom scalar and read function values. What made this especially difficult is the need to maintain backwards compatibility with existing cache reads (i.e. we can’t just return a defer-aware result all-of-a-sudden when you don’t expect it) and handle distinguishing between partial data inside @defer boundaries vs non-deferred or non-streamed fields. After all, the reason the cache result wasn’t applied to begin with was to avoid returning partial data where it wasn’t expected.
This led to the introduction of data pruning during cache reads: the ability to strip away data at @defer boundaries or @stream arrays in order to satisfy the data needs for the query while maintaining completeness guarantees. The approach first performed a cache read for all fields in the query to assemble the object while noting the location of defer boundaries and the completeness at those locations. A 2nd pass is then performed over the data to prune any data at incremental boundaries marked as partial. If you’re curious how this came together, see #13347 which focused on the changes to make this work.
This worked great! We could now apply query value transformed by the cache to the end result without leading partial data into the end result. But this led to the discovery of another data consistency issue. To illustrate, let’s use the following query as an example:
1query {2 user {3 id4 name5 ... @defer {6 email7 }8 }9}And a cache that looks like the following:
1{2 ROOT_QUERY: {3 user: "User:1",4 },5 "User:1": {6 "id": "1",7 "email": "user@example.com",8 }9}Note how the cache data contains a value for email (which is the deferred field), but not name. Executing this query with a cache-first or cache-and-network fetch policy correctly returns data as undefined for the first value because the result is partial:
1const result = useQuery(QUERY)2// => { data: undefined, dataState: "empty", loading: true, ... }But something interesting happens when the first chunk arrives from the server. The server emits the first chunk which contains the name, but not the email. Once name is written to the cache, the cache can now fulfill the entirety of the query, but should it actually return email even though the network hadn’t delivered it yet? Prior to 4.3, the full cache result was delivered after the first chunk:
1const result = useQuery(QUERY)2// initial result3// => { data: undefined, dataState: "empty", loading: true, networkStatus: 1 }45// after first chunk6// => { 7// data: {8// user: {9// __typename: "User",10// id: "1",11// name: "Test User",12// email: "user@example.com"13// }14// }, 15// dataState: "complete", 16// loading: true, 17// networkStatus: 9 18// }While this might seem ok, consider what happens if the incremental chunk fails to deliver the result for email and returns an error instead. Should the result strip away the email since it was never delivered? Or should it keep the possibly stale cache value? Had email not been written to the cache, this same query would have returned a final result without email. This is awkward for the cache-first fetch policy anyways because the result went from an empty result to a complete result while it’s still loading.
We felt that these experiences should be united and consistent across each scenario. 4.3 can now conditionally prune complete data inside an incremental boundary until its value is delivered from the network. This provides a more predictable stream of data that more closely follows the values delivered from the network.
1const result = useQuery(QUERY)2// initial result3// => { data: undefined, dataState: "empty", loading: true, networkStatus: 1 }45// after first chunk6// => { 7// data: {8// user: {9// __typename: "User",10// id: "1",11// name: "Test User",12// }13// }, 14// dataState: "streaming", 15// loading: true, 16// networkStatus: 9 17// }1819// after final chunk20// => { 21// data: {22// user: {23// __typename: "User",24// id: "1",25// name: "Test User",26// email: "user@example.com"27// }28// }, 29// dataState: "complete", 30// loading: false, 31// networkStatus: 7 32// }Cache feuds
Custom scalars also revealed another behavior where parsed scalar values failed to return when the client detected cache feuds. A cache feud is when two or more queries compete over an overlapping field’s data such that fetching and writing data from one query causes another query to be incomplete and refetch and vice versa. Cache feud detection, introduced as far back as 3.0.0, kicks in and avoids infinite network fetches between the queries.
Prior to 4.3, cache feud detection worked by recording the last written query data after a network request. If a new network request was made that returned identical data to the previous write (presumably because the fetch was caused by the feud), Apollo Client would skip the cache write and return the raw network result.
Like incremental queries, the cache result contains the transformed values that need to be applied to the result in order to get parsed scalar values. In order to return those transformed values, we needed a new way to detect cache feuds in a way that let each query retain a copy of the complete data while avoiding infinite refetches.
4.3 changes the feud detection logic. Rather than storing the previous network result and avoiding cache writes, the result is always written to the cache so that a complete cache result can be applied to the network result. To avoid infinite refetches, Apollo Client instead records the missing fields and compares them across cache broadcasts after a cache write. If multiple cache writes cause the same missing fields, Apollo Client prevents the refetch. This approach allows Apollo Client to keep the last known complete result (which contains the transformed values) while preventing infinite refetches. In fact, this new approach solved a long-standing issue that the old feud detection logic couldn’t catch where new data returned by the server on each fetch caused infinite refetches!
Additionally, we’ve made it easier to debug when these situations are happening by emitting a warning when feud detection kicks in so that you can figure out what might be overwriting these values.

If you want to see the final result, check out #13406!
Other notable changes
- The minimum supported TypeScript version is now 5.9 (#13421)
- Support s
kipTokeninuseSubscription(#13386 – Thanks @atharv-sys32!) - Add ability to override the accepted values for the
fromoption to be more strict (#13337 – Thanks @jcostello-atlassian!)
Wrapping up
Custom scalars is a feature the community asked for again and again. Along the way we made @defer, @stream, and cache consistency work together more predictably, and we fixed long-standing bugs in the process.
Ready to upgrade? Install Apollo Client 4.3 today:
1npm install @apollo/client@latestFor the full list of changes, check out the release notes. Questions and feedback are always welcome in the Apollo Community.
Happy querying!