When should a GraphQL list field take one filter input object instead of separate scalar arguments?
answer
- Neither shape is in the specification
- Count the predicates, then count the fields
- One name for generated clients to bind
- Nullable fields advertise untested combinations
- Never one shared filter for the graph
basics
~20 sUse separate arguments for a small, stable predicate set: each is named and deprecable on its own, and one can be genuinely required. Move to a single filter input once the set grows or a sibling field must accept the same predicates.
solid answer
~50 sNeither shape is prescribed — a field may declare any number of arguments, and grouping them is a design decision about how the field will change. Separate arguments give each predicate its own name, type, default and description in introspection, let one of them be non-null and therefore genuinely required, and let one be deprecated without touching the rest. They stop scaling around four: the signature becomes unreadable, every call site declares a variable per predicate, and a sibling count field has to restate the whole list. One input object named for the resource — `SensorReadingFilter` — gives the document a single variable, gives a typed client generator one type name, and can be shared by the fields that must agree. Its cost is that every optional field is nullable, so the type advertises combinations you may not be able to serve, and the implicit AND between them is undocumented.
code
graphql · 19 linestype Query {
sensorReadings(
stationId: ID
metric: MetricKind
recordedAfter: DateTime
recordedBefore: DateTime
): [SensorReading!]!
}
input SensorReadingFilter {
stationId: ID
metric: MetricKind
recordedAfter: DateTime
recordedBefore: DateTime
}
type Query {
sensorReadingsV2(filter: SensorReadingFilter): [SensorReading!]!
}go deeper
Be ready to write both shapes and say that neither is required by the specification. Knowing that a filter input's optional fields must be nullable is the detail most often missed at this level.
Explain the tradeoff mechanically: individual naming, defaults, requiredness and deprecation on one side; one variable, one bindable type name, reuse across sibling fields and room to grow on the other.
Show judgement about the combination gap — a nullable filter advertises a cross-product the backend may not serve — and about the shared-filter trap that turns one type into everybody's union.
Own the house rule and its enforcement: when a resource earns its own filter type, what a reviewer checks before a field is added to one, and how a schema-wide migration off scalar arguments is sequenced without breaking documents.
## The two shapes on the same field ```graphql # separate scalar arguments type Query { sensorReadings( stationId: ID metric: MetricKind recordedAfter: DateTime recordedBefore: DateTime ): [SensorReading!]! } # one filter input object input SensorReadingFilter { stationId: ID metric: MetricKind recordedAfter: DateTime recordedBefore: DateTime } type Query { sensorReadings(filter: SensorReadingFilter): [SensorReading!]! } ``` Neither is prescribed. The specification says a field may declare any number of arguments and that each one's type must be an input type; grouping them is a design choice, and it is a choice about how the field will change over the next three years, not about how it reads today. ## What separate arguments are better at **Each predicate is a first-class, individually described thing.** It has its own name, its own type, its own default, and its own description in introspection. A reader of the schema sees four filters, not one type they have to open. **Each one can be required, independently.** `stationId: ID!` alongside optional date bounds is a shape the type system genuinely enforces. Inside an input object, requiredness of one field among several is much harder to express usefully. **Each one can be retired on its own.** Since the 2021 edition, `@deprecated` may be applied to an argument definition, so a filter that is being phased out can be marked without touching the others. **They read well at the call site for small sets.** `sensorReadings(stationId: $id, metric: TEMPERATURE)` needs no wrapper object and no extra type name. ## What they are worse at They do not scale. A field that grows to nine or twelve filter arguments is unreadable, and every document that calls it declares one variable per filter it uses. Adding a filter means editing the field signature, and there is no single name a typed client generator can bind to — the argument list is anonymous, so the generated request type is synthesised per call site rather than shared. And nothing is reusable: `sensorReadings`, `sensorReadingCount` and an export field each restate the same four arguments, and they drift. ## What the input object is better at **One variable, one type name, one place to grow.** The document declares `$filter: SensorReadingFilter` once. Adding `sensorSerial: String` to the input type is a single additive edit that no existing document has to notice. **Reuse across the fields that must agree.** The list field, the count field and the export field can take the same filter type, which makes it structurally impossible for the count to accept a predicate the list does not. **Room for structure later.** An input object can grow nested pieces — a range sub-input, or an `any`/`all` list — without changing the field's argument list. Separate arguments have no room for that. ## What it costs Every optional field inside an input object is nullable, and nullable means "may be omitted". So the type says nothing about which *combinations* are supported. `SensorReadingFilter` with four nullable fields advertises sixteen combinations, and if only some of them are backed by an index, the schema is lying by omission. The combination rule itself — almost always an implicit AND across the provided fields — is undocumented unless the description says so; the type system has no way to express it. The wrapper also raises the question of what an explicitly-null field means as against an omitted one, which is a general input-object concern rather than a filtering one, and it invites the worst trap in this area: one shared `Filter` input reused across unrelated resources. That type has to be a union of everybody's predicates, every field has to be nullable, and it ends up describing nothing. One filter input per list resource; never one for the graph. ## A working rule Up to roughly three or four stable predicates that are unlikely to grow, use separate arguments — they are more legible and each one can carry its own requiredness. Beyond that, or as soon as a second field must accept the same predicate set, use one input object named for the resource. If you start with separate arguments and later want the object, adding it alongside and deprecating the old arguments is the additive path; swapping one for the other in place is a breaking change to every existing document. ## What to say in an interview Frame it as legibility versus evolvability, and be concrete about the cost of the wrapper — nullable fields that advertise combinations you may not be able to serve, and the shared-filter trap. A candidate who says "always use an input object because that is the convention" has skipped the entire question.
- If every field in a filter input is nullable, how does a client know which combinations the server actually supports?From the field's description, or by trying. The type system cannot express "these two go together" or "one of these is required", so a four-field filter advertises sixteen combinations regardless of how many are indexed. That gap is the main cost of the wrapper, and the honest response is to document the supported combinations and, where a combination is genuinely unsupportable, not to expose the field that enables it.
- What is wrong with defining one shared `Filter` input used by every list field in the schema?It has to be the union of every resource's predicates, so every field must be nullable and most are meaningless on any given list. Introspection then tells a client that a station-only predicate applies to a reading list, and the resolver silently ignores it. One filter input per list resource keeps each type small enough to mean something.
- How do you move an existing field from separate arguments to a filter input without breaking clients?Not in place — removing the arguments breaks every document that passes them. Add the `filter` argument alongside, have the resolver accept either, mark the old arguments `@deprecated(reason:)`, and remove them once usage evidence shows no document sends them. The alternative is a new field with the shape you want and a deprecation on the old one.
Separate arguments are labelled dials on a panel; a filter input is a form. Four dials are clearer than a form. Twelve dials are a cockpit nobody can read.
saying these in an interview costs you the question
- Says the specification requires a filter input object
- Uses one shared Filter input across unrelated resources
- Thinks nullable filter fields document supported combinations
- Cannot name a cost of the input-object wrapper
- Swaps arguments for an input in place and calls it additive
- Adds a twelfth scalar argument rather than grouping