Querying with GraphQL optional arguments in Elm

Amanda Beiner

I was recently working on integrating an Elm app with a Node GraphQL API. I had previously worked on a React/TypeScript project with a GraphQL API and had a great developer experience using generated types from Apollo Tooling’s CodeGen CLI. It automatically generated types for query response based on the GraphQL operations used in the app. This meant that we didn’t have to manually write types for every GraphQL fragment used throughout the app.

I was looking for something similar for elm, and used dillonkearns/elm-graphql on the recommendation of several colleagues. In the end, the development experience wasn’t very similar to Apollo codegen— it was game changing.

Elm and GraphQL: A Love Story ❤️

Pairing GraphQL’s schema definition language with a declarative and strongly typed language like Elm in the client enhances the benefits of both by enforcing the contract between the API and the client. By introspecting on the schema, we can infer:

  • What queries and mutations we have access to
  • What information those queries and mutations need in order to run successfully
  • What the response will look like

Leveraging Elm’s robust type system means that we can automate how to build query and mutation functions and how to decode their responses. As a result, we can be sure that if our code is compiling, the query is:

  • Being constructed correctly
  • Receiving all necessary arguments
  • Responding predictably
  • Decoded correctly

Building a GraphQL query in Elm

For an evergreen To Do app, our GraphQL mutation to create a new Todo might look something like this:

mutation {
  createTodo(title: "Walk the pupper") {
    id
    title
  }
}

We can generate types based on that schema by running elm-graphql https://example.com/graphql against our GraphQL endpoint. You’ll notice that elm-graphql created an Api directory with a bunch of pre-generated files. Using those functions, we can write an Elm query that looks like this:

type alias Todo =
  { id : Api.Scalar.Id
  , title : String
  }

mutation : String -> Cmd Msg
mutation title =
  Api.Mutation.createTodo { title = title } todoFragment
    |> Graphql.Http.queryRequest "https://example.com/graphql"
    |> Graphql.Http.send TodoCreated

todoFragment : Graphql.SelectionSet.SelectionSet Todo Api.Object.Todo
todoFragment =
  Graphql.SelectionSet.map2 Todo
    Todo.id
    Todo.title

Here, we define a Todo type alias, which represents the shape to which our decoded response value should conform. The Mutation module has a function createTodo that was generated from our schema. We pass the createTodo function a record containing arguments for the mutation, as well as a todoFragment that describes that values that we would like to get back.

Our todoFragment is an elm-graphql SelectionSet, which takes two type variables. The first parameter is the kind of data structure we want to receive after running our mutation. In this case, it’s the Todo type alias that we defined above. The second type parameter, often called the “typelock”, lets the compiler know what selection sets are valid to compose together. In this case, we’re using the generated Api.Object.Todo type.

Once we’ve passed in our required arguments and defined what fields we want returned, we have Graphql.Http execute that query, ultimately returning the TodoCreated message with the decoded query result.

That’s it. We’ve successfully abstracted away the implementation details of building and decoding a query.

Handling Optional Arguments

Let’s say we got feedback that users wanted to be able to add notes to their Todos so that they can add links or other necessary information. We update the GraphQL schema to have a nullable notes field.

type Mutation {
  createTodo(title: String!, notes: String) : Todo
}

The notes argument is optional. We can tell because it doesn’t have a ! in the schema definition. How do we handle optional arguments in a language without a concept of optional arguments?

Types of nothing: a quick aside

The GraphQL spec identifies two different ways to represent the lack of a value: it can be explicitly null, or just missing. Which means that there are three different ways we might want to execute our createTodo mutation: with a notes value, without a notes value, or with a null notes value.

// With a notes
mutation {
  createTodo(title: "Walk the pupper", notes: "5 laps around the block" ) {
    // desired fields here
  }
}

// Witout a notes
mutation {
  createTodo(title: "Walk the pupper") {
    // desired fields here
  }
}

// Null notes
mutation {
  createTodo(title: "Walk the pupper", notes: null) {
    // desired fields here
  }
}

In this case, I have a specific goal in mind that I want to remember, so I create a Todo with a title and a notes field:

mutation {
  createTodo(title: "Walk the pupper", notes: "5 laps around the block" ) {
    // desired fields here
  }
}

But oh no! I forgot that I actually have 3 puppers, and I want to make sure that I walk them all. So I want to execute a mutation to change the title of my Todo to account for all of my puppers. I don’t want to pass a value in for the notes, so I just set the argument to null

mutation {
  editTodo(id: 1, title: "Walk all three of the puppers", notes: null) {
    id
    title
    notes
  }
}

The response from this mutation will look something like:

{
  "data": {
    "todo": {
      id: 1,
      title: "Walk all three of the puppers",
      "notes": "null"
    }
  }
}

I’ve lost my note that I want to walk five laps! Differentiating between an argument that is null and an argument that is missing allows us to update data without having to re-enter all of the data we want to keep the same. Instead of having to re-write my note about how many laps I want to do, I can simply say:

mutation {
  editTodo(id: 1, title: "Walk all three of the puppers") {
    id
    title
    notes
  }
}

In this case, we haven’t passed a value for notes, because we just want to leave it alone. If I later decide I want to delete my notes, I have the option of setting it to null explicitly.

dillonkearns/elm-graphql represents these three states through the OptionalArgument type, which is defined as:

type OptionalArgument a
    = Present a
    | Absent
    | Null

In the case of creating or editing a Todo, we’ll probably want to represent notes as a Maybe String in our Elm logic, but be sure to convert it to an OptionalArgument type before sending it to GraphQL. The OptionalArgument has some useful functions for converting a Maybe into Present, Absent, or Null values. This allows us to be sure that we are either setting notes to a string or null, or just leaving its current value alone.

Passing in Optional Arguments

The type signature for the createTodo mutation that allows optional arguments now looks like this:

type alias CreateTodoRequiredArguments =
  { title : String }

type alias CreateTodoOptionalArguments =
  { notes : OptionalArgument String }

todos : (CreateTodoOptionalArguments -> CreateTodoOptionalArguments) -> CreateTodoRequiredArguments -> SelectionSet decodesTo Api.Object.Todo -> SelectionSet decodesTo RootQuery

Now the first parameter handles our optional arguments, and the second one handles our required arguments. Unlike the required arguments, the optional arguments aren’t passed through as a record of values — it’s a function that takes in CreateTodoOptionalArguments and returns CreateTodoOptionalArguments.

That’s because if we look in our createTodo query implementation, we’ll see the following let statement:

 createTodo fillInOptionals requiredArgs object_ =
    let
      filledInOptionals =
        fillInOptionals { notes = Absent }
    in

This query function passes in a default value of { notes = Absent } to our fillInOptionals function. If we want to overwrite this default, we’ll have to write a function that accepts this { notes = Absent } record and overwrites its value:

mutation : String -> Maybe String -> Cmd Msg
mutation title notes =
  Query.todos (\optionals ->
      { optionals
        | notes =
          OptionalArgument.fromMaybe notes
      }
    )
    { userId = userId }
    todoFragment
    |> Graphql.Http.queryRequest "https://example.com/graphql"
    |> Graphql.Http.send TodoCreated

Here, we write a function that takes in the default { notes = Absent } value, and replaces the value with the result of OptionalArgument.fromMaybe notes. If a user has not entered any notes, this value will continue to be { notes = Absent }. But if a user does enter notes, it will change to { notes = Present "example" }.

Using the identity Function

Let’s say we’re adding a new page in our app that has a quick-add feature, which allows a user to quickly add a Todo with only a title field. The notes will never exist here, so we don’t have to worry about using it to overwrite the optional values— we always want it to be Absent. We can simply tell the mutation to always return the default.

mutation : String -> Cmd Msg
mutation title =
  Mutation.createTodo (\optionals -> optionals) { title = title } todoFragment
    |> Graphql.Http.mutationRequest "https://example.com/graphql"
    |> Graphql.Http.send TodoCreated

Here, we wrote a function that takes in a value and returns that value. You can replace that lambda with the identity function:

identity : a -> a

Just like the anonymous function we wrote above, the identity function returns whatever argument it is given. That means we can rewrite the mutation to look like:

mutation : String -> Cmd Msg
mutation title =
  Mutation.createTodo identity { title = title } todoFragment
    |> Graphql.Http.mutationRequest "https://example.com/graphql"
    |> Graphql.Http.send TodoCreated

With either syntax, the Mutation.createTodo function will receive { notes = Absent } and return { notes = Absent }. You might see identity used this way in the wild.

Wrap up

Elm and GraphQL are a natural fit, and the dillonkearns/elm-graphql library makes this interop feel seamless. With a little understanding around the edges of handling optional arguments in Elm, we can fully leverage the promise of type safe HTTP requests.