Skip to main content

Interfaces

Interfaces are defined by tagging a record with @gql.interface:

/** An entity with a name. */
@gql.interface
type hasName = {
/** The name of the thing. */
@gql.field name: string
}
"""
An entity with a name.
"""
interface HasName {
"""
The name of the thing.
"""
name: String!
}

Implementing an interface

You implement an interface on a type (or other interface) by spreading that interface on that type definition. Example:

/** An entity with a name. */
@gql.interface
type hasName = {
/** The name of the thing. */
@gql.field name: string
}

/** A user in the system. */
@gql.type
type user = {
...hasName,
@gql.field age: int
}
"""
An entity with a name.
"""
interface HasName {
"""
The name of the thing.
"""
name: String!
}

type User implements HasName {
"""
The name of the thing.
"""
name: String!

age: Int!
}

Exposing fields from the interface

Just like with fields on object types, you can expose fields on interfaces either directly via @gql.field, or by defining a function with @gql.field.

When defining a function you add the interface type as the first unlabelled argument so ResGraph understands this field is for the interface. Each type implementing that interface will then get a copy of that field added to it automatically. An example:

/** An entity with a name. */
@gql.interface
type hasName = {
/** The name of the thing. */
@gql.field name: string
}

/** A user in the system. */
@gql.type
type user = {
...hasName,
@gql.field age: int
}

/** The initials.*/
@gql.field
let initials = (hasName: hasName) => {
Some(initialsFromName(hasName.name))
}
"""
An entity with a name.
"""
interface HasName {
"""
The name of the thing.
"""
name: String!

"""
The initials.
"""
initials: String
}

type User implements HasName {
"""
The name of the thing.
"""
name: String!

age: Int!

"""
The initials.
"""
initials: String
}

Accessing what type the interface field function is currently working on

Occasionally it'll be useful to know what type the interface field is currently working on, even if you're working on a general interface field. You can get access to that by annotating a labelled argument to the function with Interface_<interfaceName>.ImplementedBy.t. Example:

/** The ID of a node in the graph. */
let id = (node: node, ~typename: Interface_node.ImplementedBy.t) => {
switch typename {
| User => `User:${node.id}`->ResGraph.id
| Group => `Group:${node.id}`->ResGraph.id
}
}

More information on Interface_<interfaceName>.ImplementedBy.t and friends lower on this page.

Overriding interface fields per type

Sometimes you might want to override that interface field resolver per type. Just define a new field function for that particular type, and it'll take precedence over the generic interface field:

/** An entity with a name. */
@gql.interface
type hasName = {
/** The name of the thing. */
@gql.field name: string
}

/** A user in the system. */
@gql.type
type user = {
...hasName,
@gql.field age: int
}

/** The initials.*/
@gql.field
let initials = (hasName: hasName) => {
Some(initialsFromName(hasName.name))
}

// In another file UserResolvers.res
/** The user's initials.*/
@gql.field
let initials = (user: user) => {
Some(initialsFromUser(user))
}
"""
An entity with a name.
"""
interface HasName {
"""
The name of the thing.
"""
name: String!

"""
The initials.
"""
initials: String
}

type User implements HasName {
"""
The name of the thing.
"""
name: String!

age: Int!

"""
The user's initials.
"""
initials: String
}

Interfaces as return types

You can use interfaces as return types as well in your schema. In order to use an interface as a return type, leverage the autogenerated Resolver.t type for that interface from Interface_<interfaceName>.res:

let thingWithName = async (_: query, ~thingId, ~ctx: ResGraphContext.context): option<Interface_hasName.Resolver.t> => {
switch decodeThingId(thingId) {
| Some(#User(id)) => switch await ctx.dataLoaders.userById.load(~userId=id) {
| None => None
| Some(user) => Some(User(user))
}
| _ => None
}
}
interface HasName {
name: String!
}

type User implements HasName {
name: String!
}

type Query {
thingWithName(thingId: String!): HasName
}

A few things to note:

  • Each interface will have its own Interface_<interfaceName> file generated. That file will contain a Resolver module, which has a type t that you can use when you want the return type of a field to be that interface.
  • This type will ensure you return a valid type that implement that interface.
  • Because the generated type will link to GraphQL types in your application code, it's good practice to put these resolvers in their own files to avoid issues with circular dependencies.

Extras

It's often useful to know on the server what types actually implement an interface. ResGraph will automatically generate 3 helpful things in the module ImplementedBy in Interface_<interfaceName>.res to keep track of this:

  1. A type t that represent the names of all types that implement this interface.
  2. A function decode that parses a string into an option<t>.
  3. A function toString that turns t into a string.

A full example:

@gql.interface
type hasName = {
@gql.field name: string
}

@gql.type
type user = {
...hasName
}

@gql.type
type group = {
...hasName
}
interface HasName {
name: String!
}

type User {
name: String!
}

type Group {
name: String!
}

Notice both User and Group implements HasName. This generates the following helpers in Interface_hasName.res:

module ImplementedBy = {
type t = User | Group
let decode: string => option<t>
let toString: t => string
}

There, we've covered interfaces. Let's talk about custom scalars!