question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

Discussion: Stronger typing for arguments in resolvers?

See original GitHub issue

When writing resolver functions, my most common run-time errors are caused by unpacking the arguments incorrectly. This is because the API presents a Map<string, obj>, so it’s easy to get the casting wrong!

I realized that when designing the schema, we actually have all of the type information already. The issue is that it’s not passed to the resolve function!

Scroll down for a small example of the issue.

So, I set out to design an approach that carries the types from the argument list to the resolve function.

The idea is to:

  1. Provide type-safe building blocks for the built-in GraphQL types
  2. Provide functions for composing these building blocks as the user requires

Here is the main type definition:

type Args = Map<string, obj>

type Input<'t> =
  {
    InputFieldDefs : InputFieldDef list
    Extract : Args -> 't
  }

It wraps the type we already have (InputFieldDef) but adds a strongly-typed function for extracting the value during the resolve stage.

From this, we can create building blocks for built-in GraphQL types. For example, here is Int:

module Input =

  let int (name : string) (defaultValue : int option) (description : string option) : Input<int> =
    {
      InputFieldDefs =
        [
          Define.Input(name, Int, ?defaultValue=defaultValue, ?description=description)
        ]
      Extract =
        fun args ->
          match args |> Map.tryFind name with
          | Some o ->
            match o with
            | :? int as i -> i
            | _ -> failwith $"Argument \"{name}\" was not an int, it was a {o.GetType().Name}"
          | None ->
            match defaultValue with
            | Some i -> i
            | None -> failwith $"Argument \"{name}\" not found"
    }

We can combine two Input<_> objects together like this:

  let zip (a : Input<'a>) (b : Input<'b>) : Input<'a * 'b> =
    {
      InputFieldDefs = a.InputFieldDefs @ b.InputFieldDefs
      Extract =
        fun args ->
          let x = a.Extract args
          let y = b.Extract args
          x, y
    }

For example, if the resolve function expects an int and a string we can do:

Input.zip
  (Input.int "n" None None)
  (Input.string "s" None None)

And we can build a Computation Expression version of this too!

input {
  let! n = Input.int "n" None None
  and! s = Input.string "s" None None

  return n, s
}

The next step is to provide a function for creating a FieldDef that takes an Input<'t>:

module FieldDef =

  let define (name : string) (typeDef : #OutputDef<'t>) (input : Input<'arg>) resolve =
    Define.Field(
      name,
      typeDef,
      input.InputFieldDefs,
      (fun (ctx : ResolveFieldContext) x ->
        let arg = input.Extract ctx.Args

        resolve arg ctx x))

Here the resolve function takes 3 arguments instead of the usual 2. The first is the strongly-typed argument value.

And an Async version:

  let defineAsync(name : string) (typeDef : #OutputDef<'t>) (description : string) (input : Input<'arg>) resolve =
    Define.AsyncField(
      name,
      typeDef,
      description,
      input.InputFieldDefs,
      (fun (ctx : ResolveFieldContext) x ->
        async {
          let arg = input.Extract ctx.Args

          return! resolve arg ctx x
        }))

Here is a small schema that demonstrates how it all fits together:

open System

type ToDoItem =
  {
    ID : Guid
    Created : DateTime
    Title : string
    IsDone : bool
  }

type Root () =
  let mutable toDoItems = Map.empty

  member this.TryFetchToDoItem(id : Guid) =
    async {
      return Map.tryFind id toDoItems
    }

  member this.FetchToDoItems() =
    async {
      return
        toDoItems
        |> Map.toSeq
        |> Seq.map snd
        |> Seq.sortBy (fun x -> x.Created, x.ID)
        |> Seq.toList
    }

  member this.CreateToDoItem(title : string) =
    async {
      let toDoItem =
        {
          ID = Guid.NewGuid()
          Created = DateTime.UtcNow
          IsDone = false
          Title = title
        }

      toDoItems <- Map.add toDoItem.ID toDoItem toDoItems

      return toDoItem
    }

  member this.TryUpdateToDoItem(id : Guid, ?title : string, ?isDone : bool) =
    async {
      match Map.tryFind id toDoItems with
      | Some toDoItem ->
        let nextToDoItem =
          {
            toDoItem with
              Title = title |> Option.defaultValue toDoItem.Title
              IsDone = isDone |> Option.defaultValue toDoItem.IsDone
          }

        if toDoItem <> nextToDoItem then
          toDoItems <- Map.add id nextToDoItem toDoItems

        return Some nextToDoItem
      | None ->
        return None
    }

open FSharp.Data.GraphQL.Execution
open FSharp.Data.GraphQL.Types.SchemaDefinitions

let toDoItemType =
  Define.Object<ToDoItem>(
    "ToDoItem",
    [
      Define.Field("id", Guid, fun ctx x -> x.ID)
      Define.Field("created", String, fun ctx x -> x.Created.ToString("o"))
      Define.Field("title", String, fun ctx x -> x.Title)
      Define.Field("isDone", Boolean, fun ctx x -> x.IsDone)
    ]
  )

let queryType =
  Define.Object<Root>(
    "Query",
    [
      FieldDef.defineAsync
        "toDoItem"
        (Nullable toDoItemType)
        "Fetches a single to-do item"
        (Input.guid "id" None None)
        (fun g ctx (root : Root) ->
          root.TryFetchToDoItem(g))

      Define.AsyncField(
        "toDoItems",
        ListOf toDoItemType,
        "Fetches all to-do items",
        fun ctx (root : Root) ->
          root.FetchToDoItems())
    ]
  )

let mutationType =
  Define.Object<Root>(
    "Mutation",
    [
      FieldDef.defineAsync
        "createToDoItem"
        toDoItemType
        "Creates a new to-do item"
        (Input.string "title" None None)
        (fun title ctx (root : Root) ->
          root.CreateToDoItem(title))

      FieldDef.defineAsync
        "updateToDoItem"
        (Nullable toDoItemType)
        "Updates a to-do item"
        (input {
          let! id = Input.guid "id" None None
          and! title = Input.nullableString "title" None None
          and! isDone = Input.nullableBool "isDone" None None

          return id, title, isDone
        })
        (fun args ctx (root : Root) ->
          async {
            let id, title, isDone = args

            return! root.TryUpdateToDoItem(id, ?title=title, ?isDone=isDone)
          })
    ]
  )

let schema = Schema(queryType, mutationType)

I will attach a complete demo script that can run in FSI.

What does everyone think? Could we build this into the library?

Issue Analytics

  • State:open
  • Created a year ago
  • Comments:10 (2 by maintainers)

github_iconTop GitHub Comments

1reaction
njlrcommented, May 22, 2022

Maybe it is better to define an input as a record type but not as a list of definitions like now? The schema will just walk through that record properties and create definitions. And in runtime, we just deserialize the inputs JSON object right into that record. Then the record comes to your resolver as an argument.

I think that it is important for the user to be able to specify exactly what input types and names are put into the schema, and this should be somewhat independent of the underlying record type. I think reflection could be a convenient option (e.g. for the first pass at the schema), but it would not provide enough control in my view.


I realized my post does not explain the motivation for this very well, so here is a small example showing the problem with the current design:

#r "nuget: FSharp.Data.GraphQL.Server, 1.0.7"

// Domain

type Relation =
  | Friend
  | Foe

type Name = string

type Person =
  {
    Name : Name
    RelationshipTo : Name -> Relation
  }



// Schema

open FSharp.Data.GraphQL.Types

let relationType =
  Define.Enum(
    "Relation",
    [
      Define.EnumValue("FRIEND", Relation.Friend)
      Define.EnumValue("FOE", Relation.Foe)
    ]
  )

let personType =
  Define.Object<Person>(
    name = "Person",
    fields = [
      Define.Field("name", String, fun context x -> x.Name)

      Define.Field(
        "relationshipTo",
        relationType,
        [
          Define.Input("otherPerson", Int) // Mistake here, should be String
        ],
        fun (context : ResolveFieldContext) (x : Person) ->
          let otherPerson : string = context.Arg("otherPerson") // This is not really type-safe!

          x.RelationshipTo otherPerson)
    ])
0reactions
xperiandricommented, May 22, 2022

@mickhansen, @ivelten any thoughts from your sides?

Read more comments on GitHub >

github_iconTop Results From Across the Web

Typed arguments in resolvers · Issue #1419 · graphql ...
Getting types of args in resolver is useful because it gives more possibilities to process input. To validate or transform it based on...
Read more >
Writing GraphQL Resolvers
Learn how to write more complicated resolvers with custom types.
Read more >
Resolvers - Apollo GraphQL Docs
Resolver functions are passed four arguments: parent , args , contextValue , and info (in that order). You can use any name for...
Read more >
GraphQL Resolvers: Best Practices | by Mark Stuart
This post is the first part of a series of best practices and observations we have made while building GraphQL APIs at PayPal....
Read more >
A walk in GraphQL — Arguments and Variables — Day 2
Arguments, deep dive · Any field of a query , mutation or subscription operation can pass arguments along · Any field of an...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found