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
Todo
s 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.