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.