Skip to main content

New Intersection Type

At a glance

Timeline


As an alternative to "unions implementing interfaces" (https://github.com/graphql/graphql-spec/pull/939), a new intersection type could be introduced that models the same behavior.

Spec PR: https://github.com/graphql/graphql-spec/pull/941 Implementation PR: https://github.com/graphql/graphql-js/pull/3550

Example Behavior

# SomeIntersection includes TypeA and TypeB below because only they declare implementation of SomeInterface
intersection SomeIntersection = SomeUnion & SomeInterface

# AnotherIntersection only includes TypeC below because only TypeC declares implementation of AnotherInterface
intersection AnotherIntersection = AnotherUnion & AnotherInterface

union SomeUnion = TypeA | TypeB
union AnotherUnion = TypeA | TypeB | TypeC

interface SomeInterface {
someField: String
}

interface AnotherInterface {
anotherField: String
}

type TypeA implements SomeInterface {
someField: String
}
type TypeB implements SomeInterface {
someField: String
anotherField: String
}
type TypeC implements SomeInterface & AnotherInterface {
someField: String
anotherField: String
}

# to remove TypeA and TypeB from SomeIntersection
extend type intersection SomeIntersection = AnotherInterface

# to add TypeC to SomeUnion and transitively to SomeIntersection
extend type SomeUnion = TypeC

# to add TypeB to AnotherIntersection and
# if above changes were also made, to add back TypeB to SomeIntersection
extend type TypeB implements AnotherInterface

Motivation

intersection "implementing" interface

# generic types
interface Node {
id: ID!
}

interface Connection {
pageInfo: PageInfo!
edges: [Edge]
}

interface Edge {
cursor: String
node: Node
}

type PageInfo {
hasPreviousPage: Boolean
hasNextPage: Boolean
startCursor: String
endCursor: String
}

# schema-specific types
interface Pet {
id: ID!
name: String
}

type Cat implements Pet & Node {
id: ID!
name: String
}

type Dog implements Pet & Node {
id: ID!
name: String
}

union HousePet = Cat | Dog

intersection HousePetNode = HousePet & Pet & Node

# house-pet-specific types
type HousePetEdge implements Edge {
cursor: String
node: HousePetNode # <<< This is unlocked by this PR
}

type HousePetConnection implements Connection {
pageInfo: PageInfo!
edges: [HousePetEdge]
}

# query
type Query {
housePets: HousePetConnection
}

intersection "implementing" interface AND narrowing unions

# generic types
interface Node {
id: ID!
}

interface Connection {
pageInfo: PageInfo!
edges: [Edge]
}

interface Edge {
cursor: String
node: Node
}

type PageInfo {
hasPreviousPage: Boolean
hasNextPage: Boolean
startCursor: String
endCursor: String
}

# schema-specific types
interface Pet {
id: ID!
name: String
}

type Cat implements Pet & Node {
id: ID!
name: String
}

type Dog implements Pet & Node {
id: ID!
name: String
}

type Fish implements Pet & Node {
id: ID!
name: String
}

type Lion implements Pet & Node {
id: ID!
name: String
}

union HousePet = Cat | Dog | Fish
union PetWithFur = Cat | Dog | Lion

intersection HousePetWithFurNode = HousePet & PetWithFur & Pet & Node

# house-pet-specific types
type HousePetWithFurEdge implements Edge {
cursor: String
node: HousePetWithFurNode # <<< This is unlocked by this PR
}

type HousePetWithFurNodeConnection implements Connection {
pageInfo: PageInfo!
edges: [HousePetWithFurEdge]
}

# query
type Query {
housePetsWithFur: HousePetWithFurNodeConnection
}

Tradeoffs between intersections and simply allowing unions to declare "implementation" of interfaces

  1. What would happen if you wanted to add a type to your union that doesn't implement the interface? If we directly allow unions to implement interfaces, you couldn't do that without making some other change. You would basically be forced to (1) make the union no longer implement the interface, a breaking change, or (2) add a new, different union without the type that doesn't implement the interface. If we use intersections, you could add whatever types you wanted to the original union without fear.
  2. With intersections, you can also create new unions that are intersections of the previous existing unions. I doubt anyone is clamoring for that, but you sure could do that.
  3. This points the way toward higher-order abstract types in general. Working on this showed me that we could eventually allow unions to include abstract member types, which would potentially solve https://github.com/graphql/graphql-spec/issues/711

What are some of the downsides?

  1. It's more complex than the unions implement interfaces solution.
  2. Changing intersections by adding or subtracting abstract types can cause queries to break, so they are not so mutable. I'm not sure how bad that it is in practice. Adding abstract types can cause object types to no longer fulfill the intersection if they fulfilled the prior set but not the new abstract type. Removing an interface is breaking for the same reason that removing an interface from a type is breaking -- I think it's because it could break the type system, but not queries, but it's still a kind of breaking.

Open questions

  1. If intersection types include interfaces that implement interfaces, should the ancestor interfaces be required to be listed explicitly within the intersection? (My answer: yes, just like with interfaces.)
  2. Should intersection types be allow to include other intersection types? (My answer: no, the "child" intersection types should just be listed explicitly within the "parent.")
  3. Should one be allowed to query fields directly on the intersection? (My answer: no, intersections that define no union members will have no fields guarantees, so for consistency fragments on intersections should never be allowed.)
  4. What should be considered a "breaking change" with regard to an intersection? (My answer: anything that could remove an object type from the intersection or could remove an interface from the intersection.)

Example and further discussion for question 1

interface ParentInterface {
someField: String
}

interface ChildInterface implements ParentInterface {
someField String
}

type TypeA implements ParentInterface & ChildInterface {
someField: String
}

type TypeB implements ParentInterface & ChildInterface {
someField: String
}

union MyUnion = TypeA | TypeB

Should we allow:

intersection MyIntersection = MyUnion & ChildInterface

vs. requiring

intersection MyIntersection = MyUnion & ParentInterface & ChildInterface

As we can see for above, ancestor interfaces are already required for readability reasons on object and interface types. The same argument could be made here -- and I would tend to agree with it! Note that the spec/implementation PRs now implements this requirement.

Example and further discussion for question 2

interface SomeInterface {
someField: String
}

type AnotherInterface {
anotherField: String
}

type TypeA implements SomeInterface & AnotherInterface {
someField: String
anotherField: String
}

type TypeB implements AnotherInterface {
anotherField: String
}

union MyUnion = TypeA | TypeB

intersection SomeIntersection = MyUnion & SomeInterface
intersection AnotherIntersection = MyUnion & AnotherInterface

Should we allow:

intersection IntersectingIntersection = SomeIntersection & AnotherIntersection

Or require​:

intersection SomeIntersection = MyUnion & SomeInterface & AnotherInterface

One could argue that similarly to the required verbosity for interfaces, we should require​ intersections to explicitly list all of their members and not include any other intersections. The spec/PR now implements this requirement.

Example and further discussion for question 3

interface Pet {
name: String
}

type Dog implements Pet {
name: String
}

type Cat implements Pet {
name: String
}

union DogOrCat = Dog | Cat

intersection DogOrCatPet = DogOrCat & Pet

type Query {
dogOrCatPets: [DogOrCatPet]
}

Should we allow:

{
dogOrCatPets {
name
}
}

or require​:

{
dogOrCatPets {
... on Pet {
name
}
}
}

An argument in favor of allowing the former is that for this particular intersection, name is always defined, so this should be fine. An argument against allowing could be just from theory => the intersection itself does not define any fields, it just does so transitively through the interface it includes, so it is "wrong" to allow querying on the type. Because some intersections may contain only unions and not have any fields, it might be simpler to "teach" intersections as being more similar to unions, and never allow querying for fields directly on the intersection. I lean against allowing it, but not strongly.

Example and further discussion for question 4

interface Named {
name: String
}

interface AnotherInterface {
anotherField: String
}

type Dog implements Named {
name: String
}

union LandPet = Dog | Cat

intersection NamedLandPet= LandPet & Named

Intersection changes could break all sorts of queries. Adding another union or interface MAY remove types, if any of the previously included types are not in the newly added union or do not implement the newly added interface. For example, adding AnotherInterface to the intersection would cause all types to be dropped. Removing an interface could also break a type, for the same reason that removing an interface from an object type is breaking. Removing the last union causes all types to drop from the intersection.

Intersections are therefore to some degree more immutable than other types. The current implementation of this PR assumes that includes removal or addition of ANY constraining types within the results of findBreakingChanges. This could be pared back, as adding constraining types may not cause additional object types to drop if the new types are more expansive than the old. Removing a union should only cause types to drop if it is the last remaining union (at least as long as unions can only containing object types and not interfaces).