apollo-link-scalars
v5.1.0
Published
custom apollo link to allow to parse custom scalars
Readme
apollo-link-scalars
TypeDoc generated docs in here
Custom Apollo Link to parse custom scalars from GraphQL responses and serialize them back in variables. It also validates enums, can strip __typename from inputs, and now includes a cache rehydration helper for JSON-persisted Apollo caches. See Usage, Options, and Rehydrating a persisted cache (reviveScalarsInCache).
Library Versions
Apollo Client v2 -> apollo-link-scalars v0.x
The deprecated Apollo Client v2 is used in the 0.x branch.
Of the 0.x family, the versions 0.1.x and 0.2.x are deprecated and a migration to 0.3.x is recommended
Apollo Client v3 and v4 -> apollo-link-scalars v5+
apollo-link-scalars v5+ supports both Apollo Client v3 and Apollo Client v4.
The 1.x family is considered deprecated and a migration to 2.x or greater is recommended
What's new in v5
@apollo/clientv4 support alongside the existing v3 support. ThepeerDependenciesrange is now3.x || 4.x.- New
reviveScalarsInCachehelper for re-applying customparseValueto a JSON-restored Apollo cache. See Rehydrating a persisted cache (reviveScalarsInCache). - No source-level breaking changes for code already using
withScalarson4.x. Upgrading from4.0.3to5.xis a drop-in bump.
Breaking Change: removing makeExecutableSchema
The versions that included makeExecutableSchema from graphql-tools are deprecated. This are the versions:
- 0.1.x and 0.2.x => please migrate to 0.3.x (apollo client v2 line, deprecated)
- 1.x => please migrate to 2.x (apollo client v3 line)
If you are not using makeExecutableSchema from this library, the upgrade will be transparent.
If you are using makeExecutableSchema, you just need to replace it from the version of graphql-tools compatible with the version of Apollo Client that you are using. Please have a look at the Example of loading a schema
Disclaimer: Potential cache interaction
Parsing scalars at link level means that Apollo cache will receive them already parsed. Depending on what kind of parsing is performed, this may interact with the cache JSON serialization of, for example,apollo-cache-persist. While apollo-cache-persist has an option to turn that serialisation off, others may have similar issues.
In the original Apollo Client Github issue thread about scalar parsing, this situation was discussed.
Apollo Client still does not support this natively. The original 2016 ticket was closed in 2018 as a housekeeping redirect to apollographql/apollo-feature-requests#368, which has been open ever since. A potential solution of parsing after the cache might have some other issues, like returning different instances for the cached data, which may not be ideal in some situations that rely on that (e.g. react re-render control). I think some users will benefit more from the automatic parsing and serializing than the cost of the potential cache interactions.
UPDATE: @woltob surfaced the JSON-backed persistence case in issue #760. The reviveScalarsInCache helper documented below is available in apollo-link-scalars v5+.
Installation
Install the library together with graphql, plus the Apollo Client version your app already uses.
pnpm add apollo-link-scalars graphql @apollo/clientUse apollo-link-scalars v5+ if you are on @apollo/client v3 or v4.
What It Does
- Parses custom scalar fields in GraphQL responses by walking the query result with your schema.
- Serializes custom scalar input values before they are sent over the network.
- Lets
typesMapoverrides win over schema scalar implementations when you need app-specific behavior. - Optionally validates enum values and removes
__typenamefrom inputs. - Rehydrates parsed scalar values back into a JSON-restored Apollo cache with
reviveScalarsInCacheinv5+.
Working Examples
This repository includes small React/Vite apps that demonstrate the main supported scenarios:
- Apollo Client v3 example shows
withScalarswith Apollo Client v3. - Apollo Client v4 example shows
withScalarswith Apollo Client v4. - Persisted cache rehydration example shows
reviveScalarsInCacherestoring parsed scalar values after JSON-backed cache persistence. - Next.js SSR hydration example shows the Next.js
getServerSidePropsJSON boundary from issue #401, plus thereviveScalarsInCachefix on the client restore path.
Usage
At runtime you provide:
- a
GraphQLSchema - optionally, a
typesMapwith customparseValue/serializefunctions - optionally, behavior flags such as enum validation,
__typenamestripping, andnullFunctions
Build the link with withScalars() and place it before your HTTP link.
Basic Setup
import { withScalars } from "apollo-link-scalars";
import { ApolloClient, ApolloLink, HttpLink, InMemoryCache } from "@apollo/client/core";
import { schema } from "./my-schema";
const httpLink = new HttpLink({ uri: "http://example.org/graphql" });
const client = new ApolloClient({
cache: new InMemoryCache(),
link: ApolloLink.from([withScalars({ schema }), httpLink]),
});Overriding Scalar Behavior With typesMap
You can override specific scalar parsing or serialization rules with typesMap. These functions take priority over any scalar implementation already present in the schema.
import { withScalars } from "apollo-link-scalars";
import { ApolloLink, HttpLink } from "@apollo/client/core";
import { isString } from "es-toolkit";
import { schema } from "./my-schema";
const typesMap = {
CustomScalar: {
serialize: (parsed: unknown): string | null => (parsed instanceof CustomScalar ? parsed.toString() : null),
parseValue: (raw: unknown): CustomScalar | null => {
if (!raw) return null; // if for some reason we want to treat empty string as null, for example
if (isString(raw)) {
return new CustomScalar(raw);
}
throw new Error("invalid value to parse");
},
},
};
const link = ApolloLink.from([withScalars({ schema, typesMap }), new HttpLink({ uri: "http://example.org/graphql" })]);Options
withScalars() accepts these extra options:
removeTypenameFromInputs(Boolean, defaultfalse): when enabled, it will remove from the inputs the__typenameif it is found. This could be useful if we are using data received from a query as an input on another query.validateEnums(Boolean, defaultfalse): when enabled, it will validate the enums on parsing, throwing an error if it sees a value that is not one of the enum values.nullFunctions(NullFunctions, optional): by passing a set of transforms on how to box and unbox null types, you can automatically construct e.g. Maybe monads from null values. See Changing the behaviour of nullable types.ensureSerializableVariables(Boolean, defaultfalse): opt-in fix forBigInt-typed variables. Installs a defaultBigInt.prototype.toJSON(calling.toString()) globally on first link instantiation, but only if notoJSONis already defined. This is a process-wide side effect — read the warnings in UsingBigIntvariables before enabling it.
withScalars({
schema,
typesMap,
validateEnums: true,
removeTypenameFromInputs: true,
});Using BigInt variables
If you pass a BigInt value as a GraphQL variable (with a custom BigInt scalar registered in typesMap), Apollo Client throws TypeError: Do not know how to serialize a BigInt from QueryManager.readCache (cache-first queries) or markMutationResult (mutation cache writes).
This is not something withScalars can fix in the link request path: Apollo computes cache identity by calling JSON.stringify (via its internal canonicalStringify) on the variables BEFORE the link chain executes, so the link itself cannot intercept the call. JSON.stringify(1n) throws because the JavaScript spec does not define BigInt.prototype.toJSON.
The standard workaround is to install a BigInt.prototype.toJSON shim, as documented on MDN. Setting ensureSerializableVariables: true does this for you on first link instantiation:
withScalars({
schema,
typesMap,
ensureSerializableVariables: true,
});The shim that gets installed:
BigInt.prototype.toJSON = function () {
return this.toString();
};Warnings — read before enabling:
- Process-wide side effect. This modifies
BigInt.prototypefor everyBigIntin your runtime, not just those passed as GraphQL variables. Anywhere your application callsJSON.stringifyon a value containing aBigInt, the result will now be a JSON string (e.g.JSON.stringify({ id: 1n })becomes'{"id":"1"}'instead of throwing). If your codebase relies on the throw as a guardrail, this changes that behavior. - No-op if a
toJSONalready exists. The shim is installed only whenBigInt.prototype.toJSONisundefined. If your app, another library, or a polyfill has already defined one (e.g. the MDN-style envelope{ $bigint: "..." }), it is left untouched. This means the option is safe to combine with an existing shim, but the shape ofBigIntvalues inJSON.stringifyoutput will be whatever was already installed. - Wire format vs. cache identity. The shim only governs how Apollo's
canonicalStringifyproduces local cache keys. The wire format sent to your GraphQL server is still driven by theserializefunction you registered for theBigIntscalar intypesMap. The two are independent. - Choice of envelope. The default shim returns
this.toString()(e.g."123"), which mirrors how aBigIntscalar is conventionally represented on the wire. The MDN documentation also shows an envelope variant ({ $bigint: "..." }) intended for round-trip JSON between two endpoints that both recognize the envelope. The envelope variant is not appropriate for talking to a generic GraphQL server, which expects a scalar literal — if aBigIntever leaks past yourtypesMap.BigInt.serialize(e.g. due to a misconfigured scalar) the envelope shape would surface as a malformed object on the wire, while thetoStringshape surfaces as a plain string the server will usually still accept. If you want the envelope behavior anyway, install your ownBigInt.prototype.toJSONbefore constructingwithScalars(...); our installer will detect it and not overwrite. Symbol, functions, circular references. This option only addressesBigInt.Symbolvalues silently disappear underJSON.stringify, functions are dropped, circular references throw — none of those are touched.- Default is
false. Existing apps see no behavior change unless they explicitly opt in.
End-To-End Example
This is the usual shape in an application:
import { ApolloClient, ApolloLink, HttpLink, InMemoryCache } from "@apollo/client";
import { withScalars } from "apollo-link-scalars";
import { schema, typesMap } from "./graphql/scalars";
const cache = new InMemoryCache();
const httpLink = new HttpLink({ uri: "/graphql" });
export const client = new ApolloClient({
cache,
link: ApolloLink.from([withScalars({ schema, typesMap, validateEnums: true }), httpLink]),
});Example of loading a schema
import { gql } from "@apollo/client/core";
import { GraphQLScalarType, Kind } from "graphql";
import { makeExecutableSchema } from "@graphql-tools/schema";
// GraphQL Schema definition.
const typeDefs = gql`
type Query {
myList: [MyObject!]!
}
type MyObject {
day: Date
days: [Date]!
nested: MyObject
}
"represents a Date with time"
scalar Date
`;
const resolvers = {
// example of scalar type, which will parse the string into a custom class CustomDate which receives a Date object
Date: new GraphQLScalarType({
name: "Date",
serialize: (parsed: CustomDate | null) => parsed && parsed.toISOString(),
parseValue: (raw: any) => raw && new CustomDate(new Date(raw)),
parseLiteral(ast) {
if (ast.kind === Kind.STRING || ast.kind === Kind.INT) {
return new CustomDate(new Date(ast.value));
}
return null;
},
}),
};
// GraphQL Schema, required to use the link
const schema = makeExecutableSchema({
typeDefs,
resolvers,
});Synchronously creating a link instance with graphql-code-generator setup
Warning: Be sure to watch your bundle size and know what you are doing.
Codegen config to generate introspection data:
codegen.yml
---
generates:
src/__generated__/graphql.schema.json:
plugins:
- "introspection"
config:
minify: trueSynchronous code to create link instance in common scenario:
import introspectionResult from "./__generated__/graphql.schema.json";
import { buildClientSchema, IntrospectionQuery } from "graphql";
const schema = buildClientSchema(introspectionResult)
// note: sometimes it seems to be needed to cast it as Introspection Query
// `const schema = buildClientSchema((introspectionResult as unknown) as IntrospectionQuery)`
const scalarsLink = withScalars({
schema,
typesMap: { … },
});Changing the behaviour of nullable types
By passing the nullFunctions parameter to withScalars, you can change the way nullable types are handled. The default implementation leaves them as-is, i.e. null => null and value => value. If instead you want to transform nulls into a Maybe monad, you can supply functions corresponding to the following type. The examples below are based on the Maybe monad from Seidr, but any implementation will do.
type NullFunctions = {
serialize(input: any): any | null;
parseValue(raw: any | null): any;
};
const nullFunctions: NullFunctions = {
parseValue(raw: any) {
if (isNone(raw)) {
return Nothing();
} else {
return Just(raw);
}
},
serialize(input: any) {
return input.caseOf({
Just(value) {
return value;
},
Nothing() {
return null;
},
});
},
};The nullFunctions are executed after the normal parsing/serializing. The normal parsing/serializing functions are not called for null values.
Both in parsing and serializing, we have the following logic (in pseudocode):
if (isNone(value)) {
return this.nullFunctions.serialize(value);
}
const serialized = serializeNonNullValue(value);
return this.nullFunctions.serialize(serialized);if (isNone(value)) {
return this.nullFunctions.parseValue(value);
}
const parsed = parseNonNullValue(value);
return this.nullFunctions.parseValue(parsed);Rehydrating a persisted cache (reviveScalarsInCache)
withScalars runs inside the Apollo link chain, so it only parses operations flowing through the network. If you persist the Apollo cache with a JSON-backed store — apollo3-cache-persist, AsyncStorage, Redux-Persist, a custom adapter — the cache entries come back from storage as the shape JSON can hold: a custom DateTime becomes an ISO string, a custom Money becomes whatever serialize emitted, etc. The link never runs on rehydration, so the consumer never sees the parsed types. This is issue #760.
reviveScalarsInCache is a pure, schema-driven helper that fixes this. Call it on the extracted cache snapshot to re-apply the custom parseValue functions to every scalar field declared in the schema, then hand the result back to cache.restore.
import { reviveScalarsInCache, withScalars } from "apollo-link-scalars";
import { LocalStorageWrapper, persistCache } from "apollo3-cache-persist";
const cache = new InMemoryCache();
await persistCache({ cache, storage: new LocalStorageWrapper(window.localStorage) });
// `persistCache` has just repopulated the cache from storage. Revive the
// snapshot so downstream cache reads see parsed scalars again.
cache.restore(reviveScalarsInCache(cache.extract(), { schema, typesMap }));
const client = new ApolloClient({
cache,
link: ApolloLink.from([withScalars({ schema, typesMap }), httpLink]),
});Works with any JSON-backed store, including ones that hand you the raw payload directly:
const raw = JSON.parse(await AsyncStorage.getItem("apollo-cache"));
cache.restore(reviveScalarsInCache(raw, { schema, typesMap }));Use the same schema, typesMap, and nullFunctions you already use in withScalars so network responses and cache rehydration produce the same shapes.
Options:
schema(required) — the sameGraphQLSchemayou pass towithScalars.typesMap(required) — the same map you pass towithScalars. Entries here win over anyparseValuedefined on the schema scalar. Leaf types defined only on the schema are still applied (same merge behavior aswithScalars).nullFunctions(optional) — pass the same transform you pass towithScalarsif you're boxing nullable values into a Maybe monad; nullable fields are wrapped through it on rehydration, matching what the link produces on the network path. Defaults to identity.
Caveats:
- Mutates the passed snapshot in place and returns the same reference. Pass a fresh object such as
cache.extract()or aJSON.parse(...)result, not a live structure shared with the rest of the app. - Requires
__typenameon embedded non-normalized objects (Apollo's default —new InMemoryCache()adds it). Caches built withaddTypename: falseskip embedded object revival because there is no typename to look up in the schema. Top-level normalized entities still work because their__typenameis part of the cache key Apollo writes regardless. - Interfaces, unions, and enum-scalar validation are out of scope in this first pass. Scalar fields nested under an interface- or union-typed field are not revived because the helper does not resolve the runtime
__typenameon the value itself the way the parser does. - Idempotence is caller-contingent. If you run the helper twice on the same snapshot, every scalar's
parseValueruns twice. Safe only whenparseValuedetects its own output and short-circuits — e.g.(v) => typeof v === "string" ? new Date(v) : vleavesDateinstances alone on a second pass. A naive(v) => Number(v) * 100will silently corrupt a second call (150 -> 15000).
Acknowledgements
The link code is heavily based on apollo-link-response-resolver by will-heart.
While the approach in apollo-link-response-resolver is to apply resolvers based on the types taken from __typename, this follows the query and the schema to parse based on scalar types. Note that apollo-link-response-resolver is archived now
I started working on this after following the Apollo feature request https://github.com/apollographql/apollo-feature-requests/issues/2.
Development, Commits, versioning and publishing
For the current release checklist, CI publishing setup, and npm trusted publishing workflow, see RELEASING.md.
Commits and CHANGELOG
Commits should follow the Conventional Commits format. The repository enforces this with commitlint, and commit-and-tag-version uses those commit messages to determine the version bump and generate CHANGELOG.md.
If you want help composing a compliant commit message, use commitizen:
# one-off interactive commit message helper
pnpm dlx git-czThis project uses commit-and-tag-version for release commits, tags, and changelog generation.
# bump package.json version, update CHANGELOG.md, git tag the release
pnpm run versionGotcha:
pnpm version(withoutrun) hits pnpm's built-inversioncommand, which prints engine versions and ignores the package script. Always invoke the script aspnpm run versionsocommit-and-tag-versionactually runs.
You may find a tool like wip helpful for managing work in progress before you're ready to create a meaningful commit.
Release Process
The canonical release process now lives in RELEASING.md. In short:
- verify locally with
pnpm test:fullandpnpm e2e:run - run
pnpm run versionto create the release commit, changelog update, and tag - push with
git push --follow-tags origin <release-branch> - let GitHub Actions publish the package to npm via trusted publishing
First Release / Special Cases
See RELEASING.md for --first-release, --prerelease, and --sign flags.
Publish the Docs
pnpm doc:html && pnpm doc:publishThis will generate the docs and publish them in Github pages.
One-Step Release Prep
There is a single command for preparing a release candidate locally:
# Prepare a standard release
pnpm prepare-release
# Push to git
git push --follow-tags origin <release-branch>Contributors ✨
Thanks goes to these wonderful people (emoji key):
This project follows the all-contributors specification. Contributions of any kind welcome!
