Andreas Garnæs

Modeling GraphQL Type Modifiers with GADTs (part 2)

This blog post is the second in a series, which describes how to implement a type-safe GraphQL library in OCaml (see part 1 here).

In part 1, we defined two OCaml types, Graphql.typ and Graphql.field, to represent GraphQL objects, scalars and fields. We identified a field invariant which guarantees at compile-time that a schema is well-constructed: resolve functions must return values of the right type. In this part we will extend Graphql.typ to handle non-nullable types and list types – collectively known as type modifiers.

Recap

Recall our example graph from part 1, a user with an id and a name:

To represent this in GraphQL, we came up with the following OCaml type definitions:

module Graphql = struct
  type 'src typ =
    | Scalar of {
        name      : string;
        serialize : 'src -> json;
      }
    | Object of {
        name   : string;
        fields : 'src field list;
      }

  and 'src field = Field : {
    name        : string;
    output_type : 'out typ;
    resolve     : 'src -> 'out;
  } -> 'src field
end

We also defined a JSON type, which we will be using again in this part:

type json =
  | Null
  | Int    of int
  | Float  of float
  | String of string
  | Bool   of bool
  | Array  of json list
  | Object of (string * json) list

Null or not null?

In GraphQL, all types are nullable by default. If you declare a GraphQL field to be of type String, then null is a valid value for that field.

Example
When executing the following query against our example schema:

query {
  user {
    name
  }
}

… this should be a valid response:

{
  "data": {
    "user": {
      "name": null
    }
  }
}

In OCaml, there is no concept of null. Rather, if a value is optional, an option type is used instead:

type 'a option =
  | Some of 'a
  | None

(* string_option_to_json : string option -> json *)
let string_option_to_json maybe_str =
  match maybe_str with
  | Some str -> String str
  | None -> Null

Recall that the type 'src Graphql.typ means that the source type for that GraphQL type is 'src, e.g. the value Graphql.string has the type string Graphql.typ and requires a string value.

Our current type definitions do not use option types, so we do not allow any nulls at all! To do so, we need a type such as 'src option Graphql.typ, which accept either None or Some x, where x is of type 'src. To implement this, we need to turn to GADTs again.

GADT Primer
When reading a GADT such as this:

type _ foo =
  | Option : 'a -> 'a option foo

You can consider Option a function from type 'a to type 'a option foo. Once you’ve constructed a value of type 'a option foo, you can match against it and get back a value of type 'a.

Example:

(* You provide an int so the type of foo_int is int option foo *)
let foo_int = Option 42

(* extract : 'a option foo -> 'a *)
let extract foo =
  match foo with
  | Option x -> x

extract foo_int
- : int = 42

There’s more to GADTs, but this basic explanation will suffice for understanding this blog post.

Previously the definition of Graphql.typ was a regular variant type. Now we’ll make it into a GADT to allow nulls by default:

module Graphql = struct
  type _ typ =
    | Scalar : {
        name      : string;
        serialize : 'src -> json;
      } -> 'src option typ
    | Object : {
        name   : string;
        fields : 'src field list;
      } -> 'src option typ
end

With this definition, the value Graphql.string has type string option Graphql.typ, and Graphql.int has type int option Graphql.typ. This means we can now define name as a nullable field:

(* user Graphql.field *)
let name_field = Graphql.Field {
  name    = "name";
  typ     = Graphql.string;            (* <-- string option Graphql.typ *)
  resolve = fun user -> Some user.name (* <-- None would be allowed     *)
}

Let’s assume we want the user id field to not allow null though. We can add a third constructor NonNull to Graphql.typ, which given an nullable type, returns a non-nullable type:

module Graphql = struct
  type 'src typ =
    | Scalar  : (* ... *) -> 'src option typ
    | Object  : (* ... *) -> 'src option typ
    | NonNull : 'src option typ -> 'src typ
end

With this in hand, we can now enforce id to be non-nullable:

let id_field = Field {
  name = "id";
  output_type = NonNull Graphql.int; (* <-- int Graphql.typ         *)
  resolve = fun user -> user.id      (* <-- cannot return None here *)
}

Perfect! The final piece to this part of the puzzle is updating our definition of Graphql.to_json, such that it handles the new variant NonNull:

module Graphql = struct
  (* ... *)

  (* unless_null : 'a option -> ('a -> json) -> json *)
  let unless_null src f =
    match src with
    | None -> Null
    | Some src' -> f src'

  let rec to_json : 'src. 'src -> 'src typ -> json =
    fun src typ ->
      match typ with
      | Scalar s ->
          unless_null src s.serialize
      | Object o ->
          unless_null src (fun src' ->
            let members = List.map (resolve_field src') o.fields in
            Object members
          )
      | NonNull t ->
          to_json (Some src) t

  and resolve_field : 'src. 'src -> 'src field -> string * json =
    fun src (Field field) ->
      let field_src  = field.resolve src in
      let field_json = to_json field_src field.output_type in
      (field.name, field_json)

At this point the type system prevents the user from returning null (None) unless the output type of the field is actually nullable. This is an amazingly powerful feature, which both prevents null pointer errors, but also means the library does not have to do null-checks at runtime — something you’ll find in (almost) all other GraphQL server libraries.

Lists

Consider adding a field tags to our GraphQL user object, which should be a list of strings. The output of the field should thus have type string list Graphql.typ, which would allow an OCaml value such as ["tag_1"; "tag_2"].

We can approach lists very similarly to how we handled nulls, simply by extending our GADT with another constructor:

module Graphql = struct
  type 'src typ =
    | Scalar  : (* ... *) -> 'src option typ
    | Object  : (* ... *) -> 'src option typ
    | NonNull : 'src option typ -> 'src typ
    | List    : 'src typ -> 'src list option typ
end

The constructor List accepts a GraphQL type and returns a list of that type. As an example, List Graphql.string represents a list of strings and has the type string option list option Graphql.typ. You might wonder why the type is string option list option and not just string option list. The reason is that in GraphQL, lists can also be null. This leaves us with four ways to combine list and non-null:

Type Example values Description
List Graphql.string None
Some [None]
Some [Some "tag_1"; Some "tag_2"]
Nullable list of nullable strings
List (NonNull Graphql.string) None
Some ["tag_1"; "tag_2"]
Nullable list of non-nullable strings
NonNull (List Graphql.string) [None]
[Some "tag_1"; Some "tag_2"]
Non-nullable list of nullable strings
NonNull (List (NonNull Graphql.string)) ["tag_1"; "tag_2"] Non-nullable list of non-nullable strings

Lastly we need to update our to_json-function to handle the List-constructor:

module Graphql = struct
  (* ... *)

  let rec to_json : 'src. 'src -> 'src typ -> json =
    fun src typ ->
      match typ with
      | Scalar s  -> (* ... *)
      | Object o  -> (* ... *)
      | NonNull t -> (* ... *)
      | List t ->
          unless null src (fun src' ->
            let elements = List.map (fun element -> to_json element t) src' in
            List elements
          )
end

This is straightforward and the types work out perfectly.

Conclusion and Next Up

In this post we’ve extended the GraphQL core from part 1 to include the GraphQL type modifiers non-null and list. This is a feature that is typically implemented in GraphQL libraries using reflection or type casting, which in turn results in runtime errors. In OCaml we can model these type modifiers as GADTs, and by doing so, we’ve been able to maintain the compile-time guarantee that only well-constructed schemas are allowed by the type checker.

In the next blog post we’ll show how to allow introspection of the GraphQL types we’ve defined so far.

comments powered by Disqus