Skip to content

feat: split generated artifacts into separate runtime and type files #5190

@ansemb

Description

@ansemb

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:

  1. Component packages lose TypeScript types — no type information for fragments unless the compiler is manually run from within the component package.
  2. 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'.
  3. 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.

Image
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 artifactoutput 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.).
Image
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:

  1. Docblock + lint directives
  2. Type imports and definitions ($variables, $data, $key, $fragmentType)
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions