Repository: Khan/genqlient
Pull request #59 (2021-08-24 01:22:00)

Add support for fragments, part 0: design sketch
## Summary:
Having implemented support for interfaces, it's time to implement
support for fragments.  And it turns out there's actually another design
decision I hadn't really thought about when thinking about interfaces!
In this commit I add a sketch of the design -- two proposed designs
really.  This one I think will be easier to change later (via a flag),
but opinions are still welcome.

Issue: https://github.com/Khan/genqlient/issues/8

## Test plan:
read it


benjaminjkraft: > It seems like the flattened approach would also work well for spreads in an abstract scope structs are generated for each concrete type. Callers can then use type switches to handle each of the returned types.

Yeah, I think it depends on why you're using an abstract-in-abstract fragment.  If you just happen to have a fragment of useful fields lying around, it's definitely simplest if we just flatten those fields in.  But if you have a fragment because you want to pass whatever you get to some shared function that accepts `LearnableContent`, you want to have some shared <something> representing that.  One thing we might be able to do is in the case where we know whether the fragment exists or not, we skip the interface wrapper.  I can make some updates to the doc to lay that option out.

It may also be worth expanding on the four sub-cases of abstract-in-abstract, which are, if you are spreading a fragment of type `I` into a selection of type `J`, `I == J`, `I implements J`, `J implements I`, and none of the above.  (Sadly, the latter may come up more than one might expect, because we don't necessarily define all our interface-interface inheritance.  But on the other hand, neither do we have many cases where we even have a type that implements multiple interfaces; all the ones I can find are in content-editing land.)

> To aid code reuse in Go, it might be fruitful to explore embedded structs for common fields (or generally, embedded structs for abstract types) as opposed to having embedded structs that correspond to fragments.

Interesting idea -- I can see how if you want to use *interfaces* as code-sharing this might be of greater value; it could replace (or augment) the getters for each field.  Note that what you describe is compatible with either approach to fragments; we could do `type MyQueryXIA struct { MyQueryXI; F }` too.  I think -- let me know if you see any reason this isn't true -- that it should be easy to add such embedding later, if we find that the getter-methods aren't sufficient once we integrate the interfaces changes into webapp.  My feeling is if we think the getter methods are sufficient, that's simpler, but we may find places where they aren't.  But maybe I'll change my mind once I make the above updates for abstract-in-abstract spreads.

Meanwhile, since I think we're all in agreement that inline fragments should get flattened, I'll go ahead and implement that.  It may be that that will be enough for a lot of webapp's queries anyway.

Patch:
diff --git a/DESIGN.md b/DESIGN.md
index 84d7ce7..c7c2a52 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -110,13 +110,13 @@ We'll do something similar to Apollo's naming scheme.  Specifically:
 
 All of this may be configurable later.
 
-### How to support fragments and interfaces
+### How to represent interfaces
 
 Consider the following query (suppose that `a` returns interface type `I`, which may be implemented by either `T` or `U`):
 
 ```graphql
-query { a { __typename b ...f } }
-fragment f on T { c d }
+query { a { __typename b ...F } }
+fragment F on T { c d }
 ```
 
 Depending on whether the concrete type returned from `a` is `T`, we can get one of two result structures back:
@@ -128,7 +128,7 @@ Depending on whether the concrete type returned from `a` is `T`, we can get one
 
 The question is: how do we translate that to Go types?
 
-One natural option is to generate a Go type for every concrete GraphQL type the object might have, and simply inline all the fragments.  So here we would have
+**Go interfaces:** one natural option is to generate a Go type for every concrete GraphQL type the object might have, and simply inline or embed all the fragments.  So here we would have
 ```go
 type T struct{ B, C, D string }
 type U struct{ B string }
@@ -147,13 +147,18 @@ Response{A: T{B: "...", C: "...", D: "..."}}
 Response{A: U{B: "..."}}
 ```
 
-Optionally, we might define a getter-method `GetB() string` on `T` and `U`, and include it in `I`, so that if you only need `B` you don't need to type-switch.  We could also define methods `GetC() string` and `GetD() string`, and another interface
+We can also define a getter-method `GetB() string` on `T` and `U`, and include it in `I`, so that if you only need `B` you don't need to type-switch.  Or, if that's not enough, we can also define a common embedded struct corresponding to the interface, so you can extract and pass that around if you want:
+
 ```go
-type F interface { isF(); GetC() string; GetD() string }
+type IEmbed struct { B string }
+type T struct { IEmbed; C, D string }
+type U struct { IEmbed }
+type I interface { isI(); GetIEmbed() IEmbed }
 ```
-so that if you want to use the same fragment `f` to share code in several places, you can do that, because all of the relevant types will implement `F`.  To do that we'll have to have the type-names for fragment fields rooted at the fragment rather than at the query, which is probably preferable anyway since they are guaranteed to be the same for all spreads of the fragment.  (Or we can just do the methods, and let you define the interface, although you still have to root the type-names for the same reason.) 
 
-Another natural option, which looks more like the way `shurcooL/graphql` does things, is to generate a type for each fragment, and only fill in the relevant ones:
+Note that this option gives a few different ways to represent fragments specifically, discussed in the next section.
+
+**Fragment fields:** another natural option, which looks more like the way `shurcooL/graphql` does things, is to generate a type for each fragment, and only fill in the relevant ones:
 ```go
 type Response struct {
     A struct {
@@ -183,13 +188,13 @@ query { a { b } }
 using the same schema as above.  In the former approach, we still define three types (plus two receivers); and `resp.A` is still of an interface type; it might be either `T` or `U`.  In the latter approach, this looks just like any other query: `resp.A` is a `struct{ B string }`.  This has implications for how we use this data: the latter approach lets us just do `resp.A.B`, whereas the former requires we do a type-switch, or add a `GetB()` method to `I`, and do `resp.A.GetB()`.
 
 
-Pros of the first approach:
+**Pros of Go interfaces:**
 
 - it's the most natural translation of how GraphQL does things, and provides the clearest guarantees about what is set when, what is mutually exclusive, etc.
 - you always know what type you got back
 - you always know which fields are there -- you don't have to encode at the application level an assumption that if fragment A was defined, fragment B also will be, because all types that match A also match B
 
-Pros of the second approach:
+**Pros of fragment fields:**
 
 - the types are simpler, and allow the option of unnamed types
 - if you query an interface, but don't care about the types, just the shared fields, you don't even have to think about any of this stuff
@@ -197,7 +202,7 @@ Pros of the second approach:
 
 Note that both approaches require that we add `__typename` to every selection set which has fragments (unless the types match exactly).  This seems fine since Apollo client also does so for all selection sets.  We also need to define `UnmarshalJSON` on every type with fragment-spreads; in the former case Go doesn't know how to unmarshal into an interface type, while in the latter the Go type structure is too different from that of the JSON.  (Note that `shurcooL/graphql` actually has its own JSON-decoder to solve this problem.)
 
-A third non-approach is to simplify define all the fields on the same struct, with some optional:
+**Flatten everything:** a third non-approach is to simplify define all the fields on the same struct, with some optional:
 
 ```go
 type Response struct {
@@ -211,7 +216,7 @@ type Response struct {
 
 Apart from being semantically messy, this doesn't have a good way to handle the case where there are types with conflicting fields, e.g.
 
-```
+```graphql
 interface I {}
 type T implements I { f: Int }
 type U implements I { f: String }
@@ -226,15 +231,157 @@ query {
 
 What type is `resp.A.F`?  It has to be both `string` and `int`.
 
-In other libraries:
-- Apollo does basically option 1, except with TypeScript/Flow's better support for sum types.
-- GraphQL Code Generator does basically option 1 (except with sum types instead of interfaces), except with unnamed types.  It definitely generates some ugly types, even with TypeScript/Flow's better support for sum types!
-- Khan's mobile autogen basically does option 1 (again with unnamed types, and sum types).
+**In other libraries:**
+- Apollo does basically the interface approach, except with TypeScript/Flow's better support for sum types.
+- GraphQL Code Generator does basically the interface approach (except with sum types instead of interfaces), except with unnamed types.  It definitely generates some ugly types, even with TypeScript/Flow's better support for sum types!
+- Khan's mobile autogen basically does the interface approach (again with unnamed types, and sum types).
 - gqlgen doesn't have this problem; on the server, fragments and interfaces are handled entirely in the framework and need not even be visible in user-land.
-- shurcooL/graphql uses option 2.
-- protobuf has a similar problem, and uses basically option 1 in Go (even though in other languages it uses something more like option 3).
+- shurcooL/graphql uses fragment fields.
+- protobuf has a similar problem, and uses basically the interface approach in Go (even though in other languages it uses something more like flattening everything).
+
+**Decision:** In general, it seems like the GraphQL Way, and perhaps also the Go Way is to use Go interfaces; I've always found the way shurcooL/graphql handles this to be a bit strange.  So I think it has to be at least the default.  In principle we could allow both though, since fragment fields are legitimately convenient for some cases, especially if you are querying shared fields of interfaces or using fragments as a code-sharing mechanism, since using Go interfaces handles those only through getter methods (although see below).
+
+### How to support fragments
+
+The previous section leaves some parts of how we'll handle fragments ambiguous.  Even within the way it lays things out, we generally have two options for how to represent a fragment.  Consider a query
+
+```graphql
+query MyQuery { a { b ...F } }
+fragment F on T { c d }
+```
+
+We'll have some struct (potentially several structs, one per implementation) representing the type of the value of `a`.  We can handle the fragment one of two ways: we can either flatten it into that type, such that the code is equivalent to writing `query Q { a { b c d } }` (except that `c` and `d` might be included for only some implementations, if `a` returns an interface), or we can represent it as a separate type `F` and embed it in the relevant structs.  (Or it could be a named field of said structs, but this seems to have little benefit, and it's nice to be able to access fields without knowing what fragment they're on.)  When the spread itself is abstract, there are a few sub-cases of the embed option.
+
+To get more concrete, there are [four cases](https://spec.graphql.org/June2018/#sec-Fragment-spread-is-possible) depending on the types in question.  (In all cases below, the methods, and other potential implementations of the interfaces, are elided.  Note also that I'm not sure I got all the type-names right exactly as genqlient would, but they should match approximately.)
+
+**Object spread in object scope:** The simplest spread is when we have an object-typed fragment spread into an object-typed selection.  (The two object types must be the same.)  This is typically used as a code-sharing mechanism.
+
+```graphql
+type Query { a: A }
+type A { b: String, c: String, d: String }
+
+query MyQuery { a { b ...F } }
+fragment F on A { c d }
+```
+
+```go
+// flattened:
+type MyQueryA struct { B, C, D string }
+
+// embedded
+type MyQueryA struct { B string; F }
+type F struct { C, D string }
+```
+
+**Abstract spread in object scope:** We can also spread an interface-typed fragment into an object-typed selection, again as a code-sharing mechanism.  (The object must implement the interface.)
+
+```graphql
+type Query { a: A }
+type A implements I { b: String, c: String, d: String }
+interface I { c: String, d: String }
+
+query MyQuery { a { b ...F } }
+fragment F on I { c d }
+```
+
+```go
+// flattened:
+type MyQueryA struct { B, C, D string }
+
+// embedded:
+type MyQueryA struct { B string; FA }
+type F interface { isF(); GetC() string; GetD() string } // for code-sharing purposes
+type FA struct { C, D string } // implements F
+```
+
+**Object spread in abstract scope:** This is the most common spread, perhaps, where you spread an object-typed fragment into an interface-typed selection in order to request some fields defined on a particular implementation of the interface.  (Again the object must implement the interface.)
+
+```graphql
+type Query { a: I }
+type A implements I { b: String, c: String, d: String }
+type T implements I { b: String, u: String, v: String }
+interface I { b: String }
+
+query MyQuery { a { b ...F ...G } }
+fragment F on A { c d }
+fragment G on A { u v }
+```
+
+```go
+// flattened:
+type MyQueryAI interface { isMyQueryAI(); GetB() string }
+type MyQueryAIA struct { B, C, D string } // implements MyQueryAI
+type MyQueryAIT struct { B, U, V string } // implements MyQueryAI
+
+// embedded:
+type MyQueryAI interface { isMyQueryAI(); GetB() string }
+type MyQueryAIA struct { B string; F } // implements MyQueryAI
+type MyQueryAIT struct { B string; G } // implements MyQueryAI
+type F struct { C, D string }
+type G struct { U, V string }
+```
+
+**Abstract spread in abstract scope:** This is a sort of combination of the last two, where you spread one interface's fragment into another interface, and can be used for code-sharing and/or to conditionally request fields.  Perhaps surprisingly, this is legal any time the two interfaces share an implementation, and neither need implement the other; this means there are arguably four cases of spreading a fragment of type `I` into a scope of type `J`: `I = J` (similar to object-in-object), `I implements J` (similar to abstract-in-object), `J implements I` (similar to object-in-abstract), and none of the above (which is quite rare).
+
+```graphql
+type Query { a: I }
+type A implements I & J { b: String, c: String, d: String }
+type T implements I { b: String }
+type U implements J { c: String, d: String, v: String }
+interface I { b: String }
+interface J { c: String, d: String }
+
+query MyQuery { a { b ...F } }
+fragment F on J { c d }
+```
+
+```go
+// flattened:
+type MyQueryAI interface { isMyQueryAI(); GetB() string }
+type MyQueryAIA struct { B, C, D string } // implements MyQueryAI (and MyQueryAJ if generated)
+type MyQueryAIT struct { B }              // implements MyQueryAI
+
+// embedded:
+type MyQueryAI interface { isMyQueryAI(); GetB() string }
+type MyQueryAIA struct { B string; FA } // implements MyQueryAI
+type MyQueryAIT struct { B string } // implements MyQueryAI
+type F interface { isF(); GetC() string; GetD() string }
+type FA struct { C, D string } // implements F
+type FU struct { C, D string } // implements F (never used; might be omitted)
+// if I == J or I implements J, perhaps additionally:
+type MyQueryAI interface { isMyQueryAI(); GetB() string; F }
+```
+
+Note in this case a third non-approach is
+
+```go
+// does not work:
+type MyQueryAI interface { isMyQueryAI(); GetB() string }
+type MyQueryAIA struct { B string; F } // implements MyQueryAI
+type F struct { C, D string }
+```
+
+This doesn't work because the fragment F might itself embed other fragments of object type.
+
+**Inline and named fragments:** Another complication is that in each of these cases, the fragment might be inline (`... on T { ... }`) or defined (`...F` where `fragment F on T { ... }`).  The latter is useful for sharing code, whereas the former may be more preferable when we just want request fields from a particular type.  Note that inline fragments have no natural name of their own; in the embedding approach we'd need to generate one.  (This is extra awkward because there's no prohibition on having several inline fragments of the same type in the same selection, e.g. `{ ... on T { id }, ... on T { name } }`, so even adding the type-name doesn't produce a unique name.)
+
+**Pros of flattening:**
+
+- Makes for the simplest resulting types, by far; the fragment adds no further complexity to the types.
+- Especially a simplification when embedding abstract-typed fragments, since you don't have to go through an extra layer of interface when you already have a concrete type.
+- More efficient when multiple fragments spread into the same selection contain the same field: we need only store it once whereas embedding must copy it once for each fragment.  Also easier to use in the same case, since if you have both `val.FragmentOne.MyField` and `val.FragmentTwo.MyField`, you can't access either via `val.MyField`.  (Empirically in the Khan Academy codebase this appears to be quite rare.)
+- If you need to manually construct values of genqlient-generated types, flattening will be a lot easier, but I don't really recommend doing that.
+
+**Pros of embedding:**
+
+- Results in cleaner type-names, since each (named) fragment can act as its own "root" for naming purposes.  (In principle we could do this even when flattening, although it might read somewhat awkwardly.)
+- Much more usable for deduplication; if you spread the fragment in several places in your query you can write a function which accepts an `F`, and pass it any of the data.  (Again we might be able to get this benefit, awkwardly, when flattening by generating an interface corresponding to each fragment, assuming we are also rooting the type-names at the fragment.)
+- It's easier to tell what fields go where; the casework for abstract-in-abstract embeds gets somewhat complex.
+- Arguably the most philosophically faithful representation of the GraphQL types in Go.
+
+Note in principle we could apply some of those benefits 
 
-**Decision:** In general, it seems like the GraphQL Way, and perhaps also the Go Way is Option 1; I've always found the way shurcooL/graphql handles this to be a bit strange.  So I think it has to be at least the default.  In principle we could allow both though, since option 2 is legitimately convenient for some cases, especially if you are querying shared fields of interfaces or using fragments as a code-sharing mechanism, since option 1 handles those only through getter methods.
+**Decision:** There are pros and cons both ways here; in general it seems embedding is the most natural where your goal is deduplication, whereas flattening is best for inline fragments; for named fragments used only once there's maybe a slight benefit to flattening but it's not a big difference either way.  If we have to go with one or the other, probably flattening is better.  But I think the best thing, unless it turns out to be too much work to implement, is probably just to flatten inline fragments and embed named ones.  (In principle we could also have a flag flatten named fragments, if we find a need.)
 
 ## Configuration and runtime
 
diff --git a/example/genqlient.yaml b/example/genqlient.yaml
index ad57911..7ef0bc7 100644
--- a/example/genqlient.yaml
+++ b/example/genqlient.yaml
@@ -5,7 +5,10 @@ operations:
 - genqlient.graphql
 generated: generated.go
 
-# We map github's DateTime type to Go's time.Time (which conveniently already
-# defines MarshalJSON and UnmarshalJSON).
-scalars:
-  DateTime: time.Time
+# We bind github's DateTime scalar type to Go's time.Time (which conveniently
+# already defines MarshalJSON and UnmarshalJSON).  This means genqlient will
+# use time.Time when a query requests a DateTime, and is required for custom
+# scalars.
+bindings:
+  DateTime:
+    type: time.Time
diff --git a/generate/config.go b/generate/config.go
index 4a07910..7ea3fcf 100644
--- a/generate/config.go
+++ b/generate/config.go
@@ -65,13 +65,25 @@ type Config struct {
 	// TODO(#5): This is a bit broken, fix it.
 	ClientGetter string `yaml:"client_getter"`
 
-	// A map from GraphQL scalar type name to Go fully-qualified type name for
-	// the types to use for any custom or builtin scalars.  By default, builtin
-	// scalars are mapped to the obvious Go types (String and ID to string, Int
-	// to int, Float to float64, and Boolean to bool), but this setting will
-	// extend or override those mappings.  These types must define MarshalJSON
-	// and UnmarshalJSON methods, or otherwise be convertible to JSON.
-	Scalars map[string]string `yaml:"scalars"`
+	// A map from GraphQL type name to Go fully-qualified type name to override
+	// the Go type genqlient will use for this GraphQL type.
+	//
+	// This is primarily used for custom scalars, or to map builtin scalars to
+	// a nonstandard type.  By default, builtin scalars are mapped to the
+	// obvious Go types (String and ID to string, Int to int, Float to float64,
+	// and Boolean to bool), but this setting will extend or override those
+	// mappings.
+	//
+	// genqlient does not validate these types in any way; they must define
+	// whatever logic is needed (MarshalJSON/UnmarshalJSON or JSON tags) to
+	// convert to/from JSON.  For this reason, it's not recommended to use this
+	// setting to map object, interface, or union types, because nothing
+	// guarantees that the fields requested in the query match those present in
+	// the Go type.
+	//
+	// To get equivalent behavior in just one query, use @genqlient(bind: ...);
+	// see GenqlientDirective.Bind for more details.
+	Bindings map[string]*TypeBinding `yaml:"bindings"`
 
 	// Set to true to use features that aren't fully ready to use.
 	//
@@ -84,6 +96,16 @@ type Config struct {
 	configFilename string
 }
 
+// A TypeBinding represents a Go type to which genqlient will bind a particular
+// GraphQL type.  See Config.Bind, above, for more details.
+type TypeBinding struct {
+	// The fully-qualified name of the Go type to which to bind.  For example:
+	//	time.Time
+	//  map[string]interface{}
+	//	github.com/you/yourpkg/subpkg.MyType
+	Type string `yaml:"type"`
+}
+
 // baseDir returns the directory of the config-file (relative to which
 // all the other paths are resolved).
 func (c *Config) baseDir() string {
diff --git a/generate/convert.go b/generate/convert.go
index ca3701d..d506615 100644
--- a/generate/convert.go
+++ b/generate/convert.go
@@ -53,7 +53,7 @@ func (g *generator) convertOperation(
 
 	goTyp, err := g.convertDefinition(
 		name, operation.Name, baseType, operation.Position,
-		operation.SelectionSet, queryOptions)
+		operation.SelectionSet, queryOptions, queryOptions)
 
 	if structType, ok := goTyp.(*goStructType); ok {
 		// Override the ordinary description; the GraphQL documentation for
@@ -140,6 +140,15 @@ func (g *generator) convertType(
 	selectionSet ast.SelectionSet,
 	options, queryOptions *GenqlientDirective,
 ) (goType, error) {
+	// We check for local bindings here, so that you can bind, say, a
+	// `[String!]` to a struct instead of a slice.  Global bindings can only
+	// bind GraphQL named types, at least for now.
+	localBinding := options.Bind
+	if localBinding != "" && localBinding != "-" {
+		goRef, err := g.addRef(localBinding)
+		return &goOpaqueType{goRef}, err
+	}
+
 	if typ.Elem != nil {
 		// Type is a list.
 		elem, err := g.convertType(
@@ -150,7 +159,7 @@ func (g *generator) convertType(
 	// If this is a builtin type or custom scalar, just refer to it.
 	def := g.schema.Types[typ.Name()]
 	goTyp, err := g.convertDefinition(
-		name, namePrefix, def, typ.Position, selectionSet, queryOptions)
+		name, namePrefix, def, typ.Position, selectionSet, options, queryOptions)
 
 	if options.GetPointer() {
 		// Whatever we get, wrap it in a pointer.  (Because of the way the
@@ -172,11 +181,16 @@ func (g *generator) convertDefinition(
 	def *ast.Definition,
 	pos *ast.Position,
 	selectionSet ast.SelectionSet,
-	queryOptions *GenqlientDirective,
+	options, queryOptions *GenqlientDirective,
 ) (goType, error) {
-	qualifiedGoName, ok := g.Config.Scalars[def.Name]
-	if ok {
-		goRef, err := g.addRef(qualifiedGoName)
+	// Check if we should use an existing type.  (This is usually true for
+	// GraphQL scalars, but we allow you to bind non-scalar types too, if you
+	// want, subject to the caveats described in Config.Bindings.)  Local
+	// bindings are checked in the caller (convertType) and never get here,
+	// unless the binding is "-" which means "ignore the global binding".
+	globalBinding, ok := g.Config.Bindings[def.Name]
+	if ok && options.Bind != "-" {
+		goRef, err := g.addRef(globalBinding.Type)
 		return &goOpaqueType{goRef}, err
 	}
 	goBuiltinName, ok := builtinTypes[def.Name]
@@ -306,7 +320,7 @@ func (g *generator) convertDefinition(
 			// preprocessQueryDocument).  But in practice it doesn't really
 			// hurt, and would be extra work to avoid, so we just leave it.
 			implTyp, err := g.convertDefinition(
-				implName, namePrefix, implDef, pos, selectionSet, queryOptions)
+				implName, namePrefix, implDef, pos, selectionSet, options, queryOptions)
 			if err != nil {
 				return nil, err
 			}
@@ -335,8 +349,9 @@ func (g *generator) convertDefinition(
 		return goType, nil
 
 	case ast.Scalar:
+		// (If you had an entry in bindings, we would have returned it above.)
 		return nil, errorf(
-			pos, "unknown scalar %v: please add it to genqlient.yaml", def.Name)
+			pos, `unknown scalar %v: please add it to "bindings" in genqlient.yaml`, def.Name)
 	default:
 		return nil, errorf(pos, "unexpected kind: %v", def.Kind)
 	}
diff --git a/generate/genqlient_directive.go b/generate/genqlient_directive.go
index ab78029..5abf8b4 100644
--- a/generate/genqlient_directive.go
+++ b/generate/genqlient_directive.go
@@ -63,6 +63,27 @@ type GenqlientDirective struct {
 	// pointer to save copies) or if you wish to distinguish between the Go
 	// zero value and null (for nullable fields).
 	Pointer *bool
+
+	// If set, this argument or field will use the given Go type instead of a
+	// genqlient-generated type.
+	//
+	// The value should be the fully-qualified type name to use for the field,
+	// for example:
+	//	time.Time
+	//	map[string]interface{}
+	//	[]github.com/you/yourpkg/subpkg.MyType
+	// Note that the type is the type of the whole field, e.g. if your field in
+	// GraphQL has type `[DateTime]`, you'd do
+	//	# @genqlient(bind: "[]time.Time")
+	// (But you're not required to; if you want to map to some type DateList,
+	// you can do that, as long as its UnmarshalJSON method can accept a list
+	// of datetimes.)
+	//
+	// See Config.Bindings for more details; this is effectively to a local
+	// version of that global setting and should be used with similar care.
+	// If set to "-", overrides any such global setting and uses a
+	// genqlient-generated type.
+	Bind string
 }
 
 func (dir *GenqlientDirective) GetOmitempty() bool { return dir.Omitempty != nil && *dir.Omitempty }
@@ -70,7 +91,6 @@ func (dir *GenqlientDirective) GetPointer() bool   { return dir.Pointer != nil &
 
 func setBool(dst **bool, v *ast.Value) error {
 	ei, err := v.Value(nil) // no vars allowed
-	// TODO: here and below, put positions on these errors
 	if err != nil {
 		return errorf(v.Position, "invalid boolean value %v: %v", v, err)
 	}
@@ -81,6 +101,18 @@ func setBool(dst **bool, v *ast.Value) error {
 	return errorf(v.Position, "expected boolean, got non-boolean value %T(%v)", ei, ei)
 }
 
+func setString(dst *string, v *ast.Value) error {
+	ei, err := v.Value(nil) // no vars allowed
+	if err != nil {
+		return errorf(v.Position, "invalid string value %v: %v", v, err)
+	}
+	if b, ok := ei.(string); ok {
+		*dst = b
+		return nil
+	}
+	return errorf(v.Position, "expected string, got non-string value %T(%v)", ei, ei)
+}
+
 func fromGraphQL(dir *ast.Directive) (*GenqlientDirective, error) {
 	if dir.Name != "genqlient" {
 		// Actually we just won't get here; we only get here if the line starts
@@ -99,6 +131,8 @@ func fromGraphQL(dir *ast.Directive) (*GenqlientDirective, error) {
 			err = setBool(&retval.Omitempty, arg.Value)
 		case "pointer":
 			err = setBool(&retval.Pointer, arg.Value)
+		case "bind":
+			err = setString(&retval.Bind, arg.Value)
 		default:
 			return nil, errorf(arg.Position, "unknown argument %v for @genqlient", arg.Name)
 		}
@@ -112,8 +146,12 @@ func fromGraphQL(dir *ast.Directive) (*GenqlientDirective, error) {
 func (dir *GenqlientDirective) validate(node interface{}) error {
 	switch node := node.(type) {
 	case *ast.OperationDefinition:
-		// Anything is valid on the entire operation; it will just apply to
-		// whatever it is relevant to.
+		if dir.Bind != "" {
+			return errorf(dir.pos, "bind may not be applied to the entire operation")
+		}
+
+		// Anything else is valid on the entire operation; it will just apply
+		// to whatever it is relevant to.
 		return nil
 	case *ast.VariableDefinition:
 		if dir.Omitempty != nil && node.Type.NonNull {
@@ -122,7 +160,7 @@ func (dir *GenqlientDirective) validate(node interface{}) error {
 		return nil
 	case *ast.Field:
 		if dir.Omitempty != nil {
-			return errorf(dir.pos, "omitempty is not appilcable to fields")
+			return errorf(dir.pos, "omitempty is not applicable to fields")
 		}
 		return nil
 	default:
@@ -138,6 +176,9 @@ func (dir *GenqlientDirective) merge(other *GenqlientDirective) *GenqlientDirect
 	if other.Pointer != nil {
 		retval.Pointer = other.Pointer
 	}
+	if other.Bind != "" {
+		retval.Bind = other.Bind
+	}
 	return &retval
 }
 
diff --git a/generate/types.go b/generate/types.go
index a5433da..f978385 100644
--- a/generate/types.go
+++ b/generate/types.go
@@ -46,8 +46,8 @@ var (
 )
 
 type (
-	// goOpaqueType represents a user-defined or builtin type, used to
-	// represent a GraphQL scalar.
+	// goOpaqueType represents a user-defined or builtin type, often used to
+	// represent a GraphQL scalar.  (See Config.Bindings for more context.)
 	goOpaqueType struct{ GoRef string }
 	// goSliceType represents the Go type []Elem, used to represent GraphQL
 	// list types.


Test patch:
diff --git a/generate/generate_test.go b/generate/generate_test.go
index f69f223..a909127 100644
--- a/generate/generate_test.go
+++ b/generate/generate_test.go
@@ -51,11 +51,13 @@ func TestGenerate(t *testing.T) {
 				Package:          "test",
 				Generated:        goFilename,
 				ExportOperations: queriesFilename,
-				Scalars: map[string]string{
-					"ID":          "github.com/Khan/genqlient/internal/testutil.ID",
-					"DateTime":    "time.Time",
-					"Junk":        "interface{}",
-					"ComplexJunk": "[]map[string]*[]*map[string]interface{}",
+				Bindings: map[string]*TypeBinding{
+					"ID":           {Type: "github.com/Khan/genqlient/internal/testutil.ID"},
+					"DateTime":     {Type: "time.Time"},
+					"Junk":         {Type: "interface{}"},
+					"ComplexJunk":  {Type: "[]map[string]*[]*map[string]interface{}"},
+					"Pokemon":      {Type: "github.com/Khan/genqlient/internal/testutil.Pokemon"},
+					"PokemonInput": {Type: "github.com/Khan/genqlient/internal/testutil.Pokemon"},
 				},
 				AllowBrokenFeatures: true,
 			})
@@ -138,9 +140,9 @@ func TestGenerateErrors(t *testing.T) {
 				Operations: []string{filepath.Join(errorsDir, sourceFilename)},
 				Package:    "test",
 				Generated:  os.DevNull,
-				Scalars: map[string]string{
-					"ValidScalar":   "string",
-					"InvalidScalar": "bogus",
+				Bindings: map[string]*TypeBinding{
+					"ValidScalar":   {Type: "string"},
+					"InvalidScalar": {Type: "bogus"},
 				},
 				AllowBrokenFeatures: true,
 			})
diff --git a/generate/testdata/queries/Pokemon.graphql b/generate/testdata/queries/Pokemon.graphql
new file mode 100644
index 0000000..5b40414
--- /dev/null
+++ b/generate/testdata/queries/Pokemon.graphql
@@ -0,0 +1,16 @@
+query GetPokemonSiblings($input: PokemonInput!) {
+  user(query: {hasPokemon: $input}) {
+    # this will override the default mapping to internal/testutil.ID:
+    # @genqlient(bind: "string")
+    id
+    # this is normally an enum, but here we make it a (list of) string:
+    # @genqlient(bind: "[]string")
+    roles
+    name
+    # this is mapped globally to internal/testutil.Pokemon:
+    pokemon { species level }
+    # this overrides said mapping:
+    # @genqlient(bind: "-")
+    genqlientPokemon: pokemon { species level }
+  }
+}
diff --git a/generate/testdata/queries/schema.graphql b/generate/testdata/queries/schema.graphql
index dc872c8..feaf9b2 100644
--- a/generate/testdata/queries/schema.graphql
+++ b/generate/testdata/queries/schema.graphql
@@ -20,6 +20,16 @@ enum Role {
   TEACHER
 }
 
+input PokemonInput {
+  species: String!
+  level: Int!
+}
+
+type Pokemon {
+  species: String!
+  level: Int!
+}
+
 """UserQueryInput is the argument to Query.users.
 
 Ideally this would support anything and everything!
@@ -33,6 +43,7 @@ input UserQueryInput {
   id: ID
   role: Role
   names: [String]
+  hasPokemon: PokemonInput
 }
 
 type AuthMethod {
@@ -53,6 +64,7 @@ type User {
   emailsWithNulls: [String]!
   emailsWithNullsOrNull: [String]
   authMethods: [AuthMethod!]!
+  pokemon: [Pokemon!]
 }
 
 """Content is implemented by various types like Article, Video, and Topic."""
diff --git a/generate/testdata/snapshots/TestGenerate-InputObject.graphql-InputObject.graphql.go b/generate/testdata/snapshots/TestGenerate-InputObject.graphql-InputObject.graphql.go
index 2f844be..288650c 100644
--- a/generate/testdata/snapshots/TestGenerate-InputObject.graphql-InputObject.graphql.go
+++ b/generate/testdata/snapshots/TestGenerate-InputObject.graphql-InputObject.graphql.go
@@ -50,9 +50,10 @@ type UserQueryInput struct {
 	Email string `json:"email"`
 	Name  string `json:"name"`
 	// id looks the user up by ID.  It's a great way to look up users.
-	Id    testutil.ID `json:"id"`
-	Role  Role        `json:"role"`
-	Names []string    `json:"names"`
+	Id         testutil.ID      `json:"id"`
+	Role       Role             `json:"role"`
+	Names      []string         `json:"names"`
+	HasPokemon testutil.Pokemon `json:"hasPokemon"`
 }
 
 func InputObjectQuery(
diff --git a/generate/testdata/snapshots/TestGenerate-Omitempty.graphql-Omitempty.graphql.go b/generate/testdata/snapshots/TestGenerate-Omitempty.graphql-Omitempty.graphql.go
index 77c2d58..4d750cd 100644
--- a/generate/testdata/snapshots/TestGenerate-Omitempty.graphql-Omitempty.graphql.go
+++ b/generate/testdata/snapshots/TestGenerate-Omitempty.graphql-Omitempty.graphql.go
@@ -66,9 +66,10 @@ type UserQueryInput struct {
 	Email string `json:"email"`
 	Name  string `json:"name"`
 	// id looks the user up by ID.  It's a great way to look up users.
-	Id    testutil.ID `json:"id"`
-	Role  Role        `json:"role"`
-	Names []string    `json:"names"`
+	Id         testutil.ID      `json:"id"`
+	Role       Role             `json:"role"`
+	Names      []string         `json:"names"`
+	HasPokemon testutil.Pokemon `json:"hasPokemon"`
 }
 
 func OmitEmptyQuery(
diff --git a/generate/testdata/snapshots/TestGenerate-Pointers.graphql-Pointers.graphql.go b/generate/testdata/snapshots/TestGenerate-Pointers.graphql-Pointers.graphql.go
index 8862ff9..0a17884 100644
--- a/generate/testdata/snapshots/TestGenerate-Pointers.graphql-Pointers.graphql.go
+++ b/generate/testdata/snapshots/TestGenerate-Pointers.graphql-Pointers.graphql.go
@@ -73,9 +73,10 @@ type UserQueryInput struct {
 	Email *string `json:"email"`
 	Name  *string `json:"name"`
 	// id looks the user up by ID.  It's a great way to look up users.
-	Id    *testutil.ID `json:"id"`
-	Role  *Role        `json:"role"`
-	Names []*string    `json:"names"`
+	Id         *testutil.ID      `json:"id"`
+	Role       *Role             `json:"role"`
+	Names      []*string         `json:"names"`
+	HasPokemon *testutil.Pokemon `json:"hasPokemon"`
 }
 
 func PointersQuery(
diff --git a/generate/testdata/snapshots/TestGenerate-PointersInline.graphql-PointersInline.graphql.go b/generate/testdata/snapshots/TestGenerate-PointersInline.graphql-PointersInline.graphql.go
index 8ab0598..d3ada1e 100644
--- a/generate/testdata/snapshots/TestGenerate-PointersInline.graphql-PointersInline.graphql.go
+++ b/generate/testdata/snapshots/TestGenerate-PointersInline.graphql-PointersInline.graphql.go
@@ -73,9 +73,10 @@ type UserQueryInput struct {
 	Email string `json:"email"`
 	Name  string `json:"name"`
 	// id looks the user up by ID.  It's a great way to look up users.
-	Id    testutil.ID `json:"id"`
-	Role  Role        `json:"role"`
-	Names []string    `json:"names"`
+	Id         testutil.ID      `json:"id"`
+	Role       Role             `json:"role"`
+	Names      []string         `json:"names"`
+	HasPokemon testutil.Pokemon `json:"hasPokemon"`
 }
 
 func PointersQuery(
diff --git a/generate/testdata/snapshots/TestGenerate-Pokemon.graphql-Pokemon.graphql.go b/generate/testdata/snapshots/TestGenerate-Pokemon.graphql-Pokemon.graphql.go
new file mode 100644
index 0000000..4a4796b
--- /dev/null
+++ b/generate/testdata/snapshots/TestGenerate-Pokemon.graphql-Pokemon.graphql.go
@@ -0,0 +1,74 @@
+package test
+
+// Code generated by github.com/Khan/genqlient, DO NOT EDIT.
+
+import (
+	"github.com/Khan/genqlient/graphql"
+	"github.com/Khan/genqlient/internal/testutil"
+)
+
+// GetPokemonSiblingsResponse is returned by GetPokemonSiblings on success.
+type GetPokemonSiblingsResponse struct {
+	// user looks up a user by some stuff.
+	//
+	// See UserQueryInput for what stuff is supported.
+	// If query is null, returns the current user.
+	User GetPokemonSiblingsUser `json:"user"`
+}
+
+// GetPokemonSiblingsUser includes the requested fields of the GraphQL type User.
+// The GraphQL type's documentation follows.
+//
+// A User is a user!
+type GetPokemonSiblingsUser struct {
+	// id is the user's ID.
+	//
+	// It is stable, unique, and opaque, like all good IDs.
+	Id               string                                   `json:"id"`
+	Roles            []string                                 `json:"roles"`
+	Name             string                                   `json:"name"`
+	Pokemon          []testutil.Pokemon                       `json:"pokemon"`
+	GenqlientPokemon []GetPokemonSiblingsUserGenqlientPokemon `json:"genqlientPokemon"`
+}
+
+// GetPokemonSiblingsUserGenqlientPokemon includes the requested fields of the GraphQL type Pokemon.
+type GetPokemonSiblingsUserGenqlientPokemon struct {
+	Species string `json:"species"`
+	Level   int    `json:"level"`
+}
+
+func GetPokemonSiblings(
+	client graphql.Client,
+	input testutil.Pokemon,
+) (*GetPokemonSiblingsResponse, error) {
+	variables := map[string]interface{}{
+		"input": input,
+	}
+
+	var retval GetPokemonSiblingsResponse
+	err := client.MakeRequest(
+		nil,
+		"GetPokemonSiblings",
+		`
+query GetPokemonSiblings ($input: PokemonInput!) {
+	user(query: {hasPokemon:$input}) {
+		id
+		roles
+		name
+		pokemon {
+			species
+			level
+		}
+		genqlientPokemon: pokemon {
+			species
+			level
+		}
+	}
+}
+`,
+		&retval,
+		variables,
+	)
+	return &retval, err
+}
+
diff --git a/generate/testdata/snapshots/TestGenerate-Pokemon.graphql-Pokemon.graphql.json b/generate/testdata/snapshots/TestGenerate-Pokemon.graphql-Pokemon.graphql.json
new file mode 100644
index 0000000..046d226
--- /dev/null
+++ b/generate/testdata/snapshots/TestGenerate-Pokemon.graphql-Pokemon.graphql.json
@@ -0,0 +1,9 @@
+{
+  "operations": [
+    {
+      "operationName": "GetPokemonSiblings",
+      "query": "\nquery GetPokemonSiblings ($input: PokemonInput!) {\n\tuser(query: {hasPokemon:$input}) {\n\t\tid\n\t\troles\n\t\tname\n\t\tpokemon {\n\t\t\tspecies\n\t\t\tlevel\n\t\t}\n\t\tgenqlientPokemon: pokemon {\n\t\t\tspecies\n\t\t\tlevel\n\t\t}\n\t}\n}\n",
+      "sourceLocation": "testdata/queries/Pokemon.graphql"
+    }
+  ]
+}
diff --git a/generate/testdata/snapshots/TestGenerate-unexported.graphql-unexported.graphql.go b/generate/testdata/snapshots/TestGenerate-unexported.graphql-unexported.graphql.go
index c07b814..f5c5b39 100644
--- a/generate/testdata/snapshots/TestGenerate-unexported.graphql-unexported.graphql.go
+++ b/generate/testdata/snapshots/TestGenerate-unexported.graphql-unexported.graphql.go
@@ -50,9 +50,10 @@ type userQueryInput struct {
 	Email string `json:"email"`
 	Name  string `json:"name"`
 	// id looks the user up by ID.  It's a great way to look up users.
-	Id    testutil.ID `json:"id"`
-	Role  Role        `json:"role"`
-	Names []string    `json:"names"`
+	Id         testutil.ID      `json:"id"`
+	Role       Role             `json:"role"`
+	Names      []string         `json:"names"`
+	HasPokemon testutil.Pokemon `json:"hasPokemon"`
 }
 
 func unexported(
diff --git a/generate/testdata/snapshots/TestGenerateErrors-UnknownScalar.go b/generate/testdata/snapshots/TestGenerateErrors-UnknownScalar.go
index c3d339b..3c698d2 100644
--- a/generate/testdata/snapshots/TestGenerateErrors-UnknownScalar.go
+++ b/generate/testdata/snapshots/TestGenerateErrors-UnknownScalar.go
@@ -1,1 +1,1 @@
-testdata/errors/UnknownScalar.schema.graphql:3: unknown scalar UnknownScalar: please add it to genqlient.yaml
+testdata/errors/UnknownScalar.schema.graphql:3: unknown scalar UnknownScalar: please add it to "bindings" in genqlient.yaml
diff --git a/generate/testdata/snapshots/TestGenerateErrors-UnknownScalar.graphql b/generate/testdata/snapshots/TestGenerateErrors-UnknownScalar.graphql
index c3d339b..3c698d2 100644
--- a/generate/testdata/snapshots/TestGenerateErrors-UnknownScalar.graphql
+++ b/generate/testdata/snapshots/TestGenerateErrors-UnknownScalar.graphql
@@ -1,1 +1,1 @@
-testdata/errors/UnknownScalar.schema.graphql:3: unknown scalar UnknownScalar: please add it to genqlient.yaml
+testdata/errors/UnknownScalar.schema.graphql:3: unknown scalar UnknownScalar: please add it to "bindings" in genqlient.yaml
diff --git a/internal/testutil/types.go b/internal/testutil/types.go
index b61b4e0..d5d5446 100644
--- a/internal/testutil/types.go
+++ b/internal/testutil/types.go
@@ -1,3 +1,12 @@
 package testutil
 
 type ID string
+
+type Pokemon struct {
+	Species string `json:"species"`
+	Level   int    `json:"level"`
+}
+
+func (p Pokemon) Battle(q Pokemon) bool {
+	return p.Level > q.Level
+}