Summary
In our monorepo at Microsoft, when using the output (artifact directory) config to avoid cross-package artifact conflicts in a monorepo, type definitions are no longer co-located with their source fragments/queries. This breaks the developer experience for shared component packages.
This proposal is adding an option to split each generated artifact into two files: a runtime artifact (placed either next to the source -same as current- or in the configured output directory) and a types-only artifact (always placed next to the source file in the default __generated__ folder with a types in the name e.g. __generated__/MyFragment.types.graphql.ts).
Problem
In a monorepo where multiple apps share component packages but have different client resolvers, the output config is necessary — without it, each app's compiler writes different runtime artifacts to the same __generated__/ location in the shared package, and whichever runs last wins. However, using output redirects all artifacts — including type definitions — away from the source files. This means:
- Component packages lose TypeScript types — no type information for fragments unless the compiler is manually run from within the component package.
- Import ergonomics degrade — types require long relative paths to the centralized
output directory (e.g., '../../../../__generated__/MyFragment.graphql') instead of co-located './__generated__/MyFragment.graphql'.
- Storybook breaks — to get types back into component packages, one could run a second compiler instance (without
output) that writes artifacts cross-package. But those artifacts contain resolver references, which breaks Storybook.
The root cause: the compiler generates a single artifact file containing both runtime code and type definitions, with no way to control where each part is written.
Why is output needed in the first place?
By default, artifacts go in __generated__/ next to the source file. In a monorepo this creates two problems:
Artifact overwrite conflicts — when app-a and app-b both compile against a shared component, they write to the same __generated__/ file. If the apps have different client resolvers, the runtime artifacts differ — whichever compiler runs last wins. On CI where builds run in parallel, this produces incorrect production builds.
Storybook resolver bleed — when a host app's compiler writes artifacts into a component package, those artifacts reference client resolvers that the component package doesn't depend on. Storybook breaks trying to bundle resolver imports outside its dependency graph.
Setting output per app isolates the runtime artifacts, but at the cost of losing co-located types.
Current workarounds (all have drawbacks)
| Workaround |
Drawback |
| Run relay compiler manually from each component package separately that dev is changing |
Poor DX; not automatic during app dev |
Maintain a separate relay.config.dev.json without output |
Two compiler instances; Storybook still breaks from resolver-containing artifacts |
Proposed Solution: Artifact Splitting
Add a config option that splits each artifact into two files:
- Runtime artifact →
output directory (or default __generated__/). Contains the node object (ConcreteRequest, ReaderFragment, etc.) used at runtime.
- Types artifact → always co-located in
__generated__/ next to the source. Contains only TypeScript type exports ($data, $key, $fragmentType, etc.).
Example: what the split files look like
Runtime artifact (app-a/src/__generated__/MyFragment.graphql.ts):
import { ReaderFragment } from 'relay-runtime';
const node: ReaderFragment = { /* ... */ };
(node as any).hash = "abc123";
export default node;
Types artifact (shared-component/src/components/__generated__/MyFragment.types.graphql.ts):
import type { FragmentType } from 'relay-runtime';
export type MyFragment$data = {
readonly name: string | null | undefined;
};
export type MyFragment$key = {
readonly " $data"?: MyFragment$data;
readonly " $fragmentSpreads": FragmentType;
};
Why this works
- No overwrite conflicts — each app's runtime artifacts live in their own
output directory; resolver references never bleed into component packages.
- Types always co-located — type artifacts are identical regardless of which compiler produces them (types depend on fragment shape, not resolver implementations), so concurrent writes are safe.
- Storybook works — component packages generate their own local runtime artifacts (no resolver imports); app compilers write to separate directories.
- Single compiler during dev — running from the app package updates both app runtime artifacts and component type artifacts in one pass.
We have implemented this in an internal fork of the Relay compiler and have been running it in our large production monorepo. Would be happy to contribute an upstream implementation.
Detailed example with artifact splitting
Package structure
packages/
user-card/ # Shared component package
src/components/
UserCard.tsx
__generated__/
UserCard_dataFragment.types.graphql.ts ← TYPES (always here)
UserCard_dataFragment.graphql.ts ← RUNTIME (for Storybook)
.storybook/
relay.config.json # No `output` set
resolvers-backend-a/src/resolvers/ # @RelayResolver for avatarUrl (backend A)
resolvers-backend-b/src/resolvers/ # Different @RelayResolver (backend B)
app-a/ # Uses resolvers-backend-a
src/__generated__/
UserCard_dataFragment.graphql.ts ← RUNTIME (backend-a refs)
relay.config.json # output: "./src/__generated__"
app-b/ # Uses resolvers-backend-b
src/__generated__/
UserCard_dataFragment.graphql.ts ← RUNTIME (backend-b refs)
relay.config.json # output: "./src/__generated__"
Compilation flow
| Compiler |
Runtime artifact |
Types artifact |
app-a |
app-a/src/__generated__/...graphql.ts (backend-a refs) |
user-card/src/components/__generated__/...types.graphql.ts |
app-b |
app-b/src/__generated__/...graphql.ts (backend-b refs) |
Same types file (identical content, safe to overwrite) |
user-card Storybook |
user-card/src/components/__generated__/...graphql.ts (local, no resolver refs) |
Same types file |
No conflicts — Storybook never sees resolver imports from host apps.
Fragment example
// user-card/src/components/UserCard.tsx
import type { UserCard_dataFragment$key } from './__generated__/UserCard_dataFragment.types.graphql';
const UserCard = ({ dataRef }: { dataRef: UserCard_dataFragment$key }) => {
const data = useFragment(
graphql`
fragment UserCard_dataFragment on User {
name
avatar { url } # resolved differently per app
}
`,
dataRef
);
return <div>{data.name}</div>;
};
Config examples
App package (app-a/relay.config.json):
{
"sources": {
"app-a/src": "app-a",
"resolvers-backend-a/src": "app-a"
},
"projects": {
"app-a": {
"output": "app-a/src/__generated__",
"artifactGenerationMode": "splitRuntimeAndTypes"
}
}
}
Component package (user-card/relay.config.json) — no output, co-locates everything:
{
"projects": {
"user-card": {
"artifactGenerationMode": "splitRuntimeAndTypes",
"schemaExtensions": ["schema-extensions/avatar"]
}
}
}
Compiler internals
Architecture already supports splitting
Artifact generation in content.rs builds an ordered list of ContentSection objects:
- Docblock + lint directives
- Type imports and definitions (
$variables, $data, $key, $fragmentType)
- Runtime node (
const node: ConcreteRequest = { ... }) + hash + export
Types (2) and runtime (3) are already constructed independently — splitting means emitting them to different files instead of concatenating.
Existing skip_types mechanism
let skip_types =
if let Some(extra_artifacts_config) = &project_config.extra_artifacts_config {
(extra_artifacts_config.skip_types_for_artifact)(source_file)
} else {
false
};
The compiler can already produce runtime-only artifacts. The complementary types-only artifact is the other half of the split.
Path resolution
project_config.rs already has both paths:
output path → runtime artifacts
- Co-located
source_file.get_dir().join("__generated__") → type artifacts
Naming convention
MyFragment.graphql.ts — runtime (backwards compatible)
MyFragment.types.graphql.ts — types only
Summary
In our monorepo at Microsoft, when using the
output(artifact directory) config to avoid cross-package artifact conflicts in a monorepo, type definitions are no longer co-located with their source fragments/queries. This breaks the developer experience for shared component packages.This proposal is adding an option to split each generated artifact into two files: a runtime artifact (placed either next to the source -same as current- or in the configured
outputdirectory) and a types-only artifact (always placed next to the source file in the default__generated__folder with atypesin the name e.g.__generated__/MyFragment.types.graphql.ts).Problem
In a monorepo where multiple apps share component packages but have different client resolvers, the
outputconfig is necessary — without it, each app's compiler writes different runtime artifacts to the same__generated__/location in the shared package, and whichever runs last wins. However, usingoutputredirects all artifacts — including type definitions — away from the source files. This means:outputdirectory (e.g.,'../../../../__generated__/MyFragment.graphql') instead of co-located'./__generated__/MyFragment.graphql'.output) that writes artifacts cross-package. But those artifacts contain resolver references, which breaks Storybook.The root cause: the compiler generates a single artifact file containing both runtime code and type definitions, with no way to control where each part is written.
Why is
outputneeded in the first place?By default, artifacts go in
__generated__/next to the source file. In a monorepo this creates two problems:Artifact overwrite conflicts — when
app-aandapp-bboth compile against a shared component, they write to the same__generated__/file. If the apps have different client resolvers, the runtime artifacts differ — whichever compiler runs last wins. On CI where builds run in parallel, this produces incorrect production builds.Storybook resolver bleed — when a host app's compiler writes artifacts into a component package, those artifacts reference client resolvers that the component package doesn't depend on. Storybook breaks trying to bundle resolver imports outside its dependency graph.
Setting
outputper app isolates the runtime artifacts, but at the cost of losing co-located types.Current workarounds (all have drawbacks)
relay.config.dev.jsonwithoutoutputProposed Solution: Artifact Splitting
Add a config option that splits each artifact into two files:
outputdirectory (or default__generated__/). Contains thenodeobject (ConcreteRequest,ReaderFragment, etc.) used at runtime.__generated__/next to the source. Contains only TypeScript type exports ($data,$key,$fragmentType, etc.).Example: what the split files look like
Runtime artifact (
app-a/src/__generated__/MyFragment.graphql.ts):Types artifact (
shared-component/src/components/__generated__/MyFragment.types.graphql.ts):Why this works
outputdirectory; resolver references never bleed into component packages.We have implemented this in an internal fork of the Relay compiler and have been running it in our large production monorepo. Would be happy to contribute an upstream implementation.
Detailed example with artifact splitting
Package structure
Compilation flow
app-aapp-a/src/__generated__/...graphql.ts(backend-a refs)user-card/src/components/__generated__/...types.graphql.tsapp-bapp-b/src/__generated__/...graphql.ts(backend-b refs)user-cardStorybookuser-card/src/components/__generated__/...graphql.ts(local, no resolver refs)No conflicts — Storybook never sees resolver imports from host apps.
Fragment example
Config examples
App package (
app-a/relay.config.json):{ "sources": { "app-a/src": "app-a", "resolvers-backend-a/src": "app-a" }, "projects": { "app-a": { "output": "app-a/src/__generated__", "artifactGenerationMode": "splitRuntimeAndTypes" } } }Component package (
user-card/relay.config.json) — nooutput, co-locates everything:{ "projects": { "user-card": { "artifactGenerationMode": "splitRuntimeAndTypes", "schemaExtensions": ["schema-extensions/avatar"] } } }Compiler internals
Architecture already supports splitting
Artifact generation in
content.rsbuilds an ordered list ofContentSectionobjects:$variables,$data,$key,$fragmentType)const node: ConcreteRequest = { ... }) + hash + exportTypes (2) and runtime (3) are already constructed independently — splitting means emitting them to different files instead of concatenating.
Existing
skip_typesmechanismThe compiler can already produce runtime-only artifacts. The complementary types-only artifact is the other half of the split.
Path resolution
project_config.rsalready has both paths:outputpath → runtime artifactssource_file.get_dir().join("__generated__")→ type artifactsNaming convention
MyFragment.graphql.ts— runtime (backwards compatible)MyFragment.types.graphql.ts— types only