Every GraphQL Tool Is Just an Introspection Client
Every GraphQL Tool Is Just an Introspection Client
A linter, a code generator, and a documentation site all need the same thing: your schema as data. They do not read your repository, they do not share your build, and they have no special protocol. They all send an ordinary GraphQL query asking the server to describe itself.
Two meta-fields on the query root provide the entire mechanism:
{ __schema { queryType { name } } } # the whole schema
{ __type(name: "Post") { kind name } } # one type by name
Both are reserved by the specification — every conforming server implements them, and you cannot rename or remove them. The types they return are also meta-types: __Schema, __Type, __Field, __InputValue, __EnumValue, __Directive, and the __TypeKind enum. Together they can describe any schema: objects with their fields and arguments, interfaces with their implementors, unions with their possible types, enums with their values, inputs with their defaults, and directives with their locations.
That self-description is why GraphQL clients need no IDL file to interoperate. A mobile client, a docs generator, and a mock server can all start from one URL. It is also why the tools you already know behave the way they do: GraphiQL's docs panel fires __schema at load (see the GraphiQL workbench topic), a codegen run either queries __schema or reads an SDL file that someone previously produced from it, and a gateway builds its router from the same document.
The consumer list is worth being concrete about:
| Consumer | What it extracts from introspection |
|---|---|
| GraphiQL / IDE plugins | fields, args, enum values for completion |
| graphql-codegen | type definitions and operation result shapes |
| Schema linters | AST to run naming and description rules against |
| Docs sites | types with descriptions and deprecation reasons |
| Mock servers | field types to synthesize fixture data |
| Gateways / registries | composition and routing metadata |
One boundary matters before going further: introspection is a read API over your schema, and like any read API it can be gated. Servers expose a switch to disable it for untrusted callers — when that switch is off, every consumer above must be given a schema artifact instead. Schema exposure control covers the policy; the rest of this topic covers the mechanics and the artifact path.
Reading the __schema Document
Reading the __schema Document
Here is a single introspection query that returns everything most tools need:
{
__schema {
queryType { name }
mutationType { name }
types {
kind
name
description
fields(includeDeprecated: true) {
name
description
args { name type { ...TypeRef } defaultValue }
type { ...TypeRef }
isDeprecated
deprecationReason
}
inputFields { name type { ...TypeRef } defaultValue }
interfaces { kind name }
enumValues(includeDeprecated: true) {
name
isDeprecated
deprecationReason
}
possibleTypes { kind name }
}
directives {
name
locations
args { name type { ...TypeRef } defaultValue }
}
}
}
fragment TypeRef on __Type {
kind
name
ofType { kind name ofType { kind name ofType { kind name ofType { kind name } } } }
}
Three details decide whether the result is usable:
Wrapper types are chains, not names. A nullable list of non-null strings [String!]! arrives as { kind: "NON_NULL", ofType: { kind: "LIST", ofType: { kind: "NON_NULL", ofType: { kind: "SCALAR", name: "String" } } } }. name is null for wrapper kinds and set only at the leaf, which is what the nested ofType selection in TypeRef is for. graphql-js's own getIntrospectionQuery() nests seven levels deep because schemas occasionally wrap that far.
Deprecated members are hidden by default. fields and enumValues take includeDeprecated with a default of false, so a tool that forgets the flag silently loses every deprecated field — and a schema diff built from that output would report false removals. Passing true also returns deprecationReason, which docs and codegen need.
__type(name:) drills in. When a tool needs one type — completion for a single object, a lint pass on one file — __type avoids shipping the whole schema document. kind tells you which sibling fields are meaningful: OBJECT has fields, INPUT_OBJECT has inputFields, UNION and INTERFACE have possibleTypes (for unions) and interfaces, ENUM has enumValues.
Once you have the response, the round trip back to SDL is three calls in graphql-js:
import { getIntrospectionQuery, buildClientSchema, printSchema } from 'graphql';
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ query: getIntrospectionQuery() }),
});
const { data } = await res.json();
const sdl = printSchema(buildClientSchema(data)); // introspection JSON -> SDL string
getIntrospectionQuery() returns the same document you would hand-write (with directive and isRepeatable detail), buildClientSchema turns the result into an in-memory schema object, and printSchema renders it as the SDL you commit, lint, and diff.
Snapshots, Artifacts, and Locked-Down Endpoints
Snapshots, Artifacts, and Locked-Down Endpoints
Production endpoints increasingly answer introspection requests with a refusal — the exposure risk outweighs the convenience for public APIs. Tooling does not stop; it switches inputs: instead of querying a live server, it reads an artifact produced at release time.
A minimal export step, run wherever the schema is authoritative (usually the repo, not production):
# schema.json for tools that speak introspection, schema.graphql for humans and linters
graphql-codegen --config introspect.ts
# or directly, with no codegen involved:
node -e "const {getIntrospectionQuery}=require('graphql');process.stdout.write(getIntrospectionQuery())" > /tmp/introspect.graphql
Committing the artifact buys four things:
- Air-gapped and offline tooling. Linters, codegen, and mock generation run without network access or credentials.
- Deterministic CI. A diff between last release's
schema.jsonand this branch's is a breaking-change check that needs no running server. That comparison is the heart of schema checks in CI. - No prod exposure. The live endpoint can keep introspection disabled while consumers still get a complete, versioned schema description from the registry or the repo.
- Reproducible builds. A client build from six months ago can regenerate identical types because its schema input is pinned in the repository.
Artifacts introduce one maintenance problem — staleness — and the fix is mechanical: regenerate in CI and fail if the committed file differs from the freshly generated one, the same dry-run idea as graphql-codegen --check. The schema-changing pull request updates artifact and code in one review, so the artifact can never drift further than one merge.
Practical notes: introspection results are large (hundreds of kilobytes for a real schema) because of the nested ofType chains and repeated metadata — either commit the printed SDL (smaller and diff-friendly) and reconstruct introspection when a tool insists, or commit minified JSON and accept the noise in reviews. Whichever you pick, pick one canonical artifact per schema and make every consumer read it; two artifacts that disagree are two schemas.
Interview framing: expect "how do you get the schema into your codegen if introspection is off in production?" The answer has three parts: export introspection (or print SDL) at the point where the schema is built, commit it as an artifact, and keep it honest with a regeneration check in CI.
Practice Problems
Write one introspection query that returns every field of the Query root type with its argument names, full argument types (including wrapper chains), and deprecation status. Then state how CI converts the response into SDL when production introspection is disabled.
Example:
Input: type Query { post(id: ID!): Post! oldSearch(q: String): [Post] @deprecated(reason: "use posts") }
Output: A __type(name: "Query") selection returning fields[ { name, type: TypeRef, args: [{ name, type: TypeRef }], isDeprecated, deprecationReason } ] with includeDeprecated: true
Query is an object type, so __type with fields(includeDeprecated: true) gives the root field list; args carry their own TypeRef chains so ID! and [Post] survive the round trip.
Optimal Solution — O(s) time, O(s) space
Drill into a single type with __type(name:) rather than walking the whole __schema document, select fields with includeDeprecated: true, and reuse a TypeRef fragment so every argument and field type keeps its wrapper chain. For CI, export the same document once at the authoritative source, commit the response, and regenerate on every schema pull request.
query IntrospectQueryRoot {
__type(name: "Query") {
kind
name
fields(includeDeprecated: true) {
name
description
isDeprecated
deprecationReason
type { ...TypeRef }
args {
name
defaultValue
type { ...TypeRef }
}
}
}
}
fragment TypeRef on __Type {
kind
name
ofType { kind name ofType { kind name ofType { kind name ofType { kind name } } } }
}
// ---- CI conversion (schema artifact path) ----
import { getIntrospectionQuery, buildClientSchema, printSchema } from 'graphql';
const sdl = printSchema(
buildClientSchema(await fetchIntrospection(process.env.SCHEMA_ARTIFACT_URL)),
);
await writeFile('schema/schema.graphql', sdl); // committed; diffs gate every PR Edge Cases:
- A root field with zero arguments (args: [])
- Deeply nested wrappers beyond four levels (raise the ofType depth or use getIntrospectionQuery)
- Schema where the query root type is renamed (look it up via __schema.queryType, not the name Query)
Quiz
1. A tool queries a field list without arguments and notices deprecated fields are missing from the result. What was omitted?
2. How is the type String! encoded in an introspection result?
3. What is the difference between __schema and __type(name: "Post")?
4. Production has introspection disabled. How does a client codegen build get its schema?
Flashcards
Question
The two introspection entry points
Click to reveal answer
Answer
__schema for the whole schema, __type(name:) for a single type — both reserved root fields.
Question
Meta-type set
Click to reveal answer
Answer
__Schema, __Type, __Field, __InputValue, __EnumValue, __Directive, plus __TypeKind and __DirectiveLocation enums.
Question
How [String!]! appears in JSON
Click to reveal answer
Answer
NON_NULL -> LIST -> NON_NULL -> SCALAR String, each wrapper a separate node linked by ofType; name only at the leaf.
Question
includeDeprecated default
Click to reveal answer
Answer
false — deprecated fields and enum values are omitted unless you pass true, and only then do you get deprecationReason.
Question
Introspection-to-SDL trio in graphql-js
Click to reveal answer
Answer
getIntrospectionQuery() to fetch, buildClientSchema() to parse, printSchema() to render SDL.
Question
Schema artifact workflow
Click to reveal answer
Answer
Export at the authoritative source, commit it, regenerate in CI and fail on drift — lets prod keep introspection off.
Revision Notes
Key Takeaways
- 1. Every GraphQL tool is an introspection client: one query, no special protocol
- 2. Wrapper types are ofType chains; names exist only at leaves
- 3. Deprecated members are hidden unless includeDeprecated: true
- 4. Artifacts replace live introspection in CI and are kept honest by a regeneration check
Interview Tips
- • Be able to write the introspection query from memory: __schema, types, fields with args, TypeRef nesting
- • Explain the ofType chain with the concrete example of [String!]! — interviewers use it to check you have read a real result
- • Have an answer ready for "introspection is off in production; how does codegen work?"
Cheat Sheet
Introspection Cheat Sheet
{
__schema {
queryType { name }
types {
kind name description
fields(includeDeprecated: true) {
name args { name type { ...TypeRef } defaultValue }
type { ...TypeRef } isDeprecated deprecationReason
}
inputFields { name type { ...TypeRef } }
enumValues(includeDeprecated: true) { name isDeprecated deprecationReason }
interfaces { name } possibleTypes { name }
}
directives { name locations args { name type { ...TypeRef } } }
}
}
fragment TypeRef on __Type {
kind name ofType { kind name ofType { kind name ofType { kind name } } }
}
Rules
- wrappers are ofType chains;
nameis null on NON_NULL/LIST fields/enumValues:includeDeprecateddefaults to false__type(name: "Post")for one type;__schemafor everything
Round trip
getIntrospectionQuery() -> buildClientSchema() -> printSchema() // SDL for commit/CI
Prod with introspection off: commit schema.json/schema.graphql, regenerate in CI, fail on drift.