DEV Community

loading...
Cover image for Dead-simple graphql with typeclasses and functional dependencies

Dead-simple graphql with typeclasses and functional dependencies

Mike Solomon
Making machines that make machines believe the machines they make are other machines.
・5 min read

When working with GraphQL queries and mutations, I want them to behave like a database: given an operation (ie upload a video), I want to look up the string I need to send, the variables the string needs and the response.

In SQL, this may be expressed like so:

select querystring, input_vars, output from graphql where action = 'upload_video';
Enter fullscreen mode Exit fullscreen mode

Typeclasses in Haskell and PureScript are like database tables. Using typeclasses, we can execute the above query purely with types. Let's see how!

The GraphQL kind and the GraphQL class

Continuing the SQL analogy, a kind is the "type" of your column or, in our program, the type of your type. There has been much ink spilled on comparing kinds to types of types, and it's not a perfect analogy, but in this case that's exactly what's going on.

In our example, each GraphQL query like upload_video will be indexed by a data type with kind GraphQL. This index will point to three things - a type representing a string to send the server (called a Symbol in PureScript), the type of the input and the type of the output.

foreign import kind GraphQL

class GraphQL (operation :: GraphQL) (gql :: Symbol) (i :: # Type) (o :: # Type) | operation -> gql i o
Enter fullscreen mode Exit fullscreen mode

Let's see two different instances (rows) of our typeclass (table) GraphQL: one to get information from Filestack and one to upload a video to 8base.

foreign import data GetFileStackUploadInfo :: GraphQL

instance graphqlGetFileStackUploadInfo ::
  GraphQL GetFileStackUploadInfo """
   query {
  fileUploadInfo {
    policy
    signature
    apiKey
    path
  }
}
""" () ( fileUploadInfo ::
        { policy :: String
        , signature :: String
        , apiKey :: String
        , path :: String
        }
    )

---------
foreign import data VideoUpload :: GraphQL

instance graphqlVideoUpload ::
  GraphQL VideoUpload """mutation ($id: ID!, $filename: String!, $fileId: String!) {
        recordingUpdate(
            filter: {
                id: $id
            }
            data: {
                video: {
                    create: {
                        filename: $filename
                        fileId: $fileId
                    }
                }
            }
        ) {
            id
            video {
                id
                downloadUrl
                shareUrl
            }
        }
    }
""" ( id :: String, filename :: String, fileId :: String ) ( recordingUpdate ::
        { id :: String
        , video ::
            { id :: String
            , downloadUrl :: String
            , shareUrl :: String
            }
        }
    )

Enter fullscreen mode Exit fullscreen mode

Each instance (row) of GraphQL is filled with four types (columns) where each type has the correct kind (datatype). For example, GetFileStackUploadInfo has the query (a multi-line string), the input variables type (in this case an empty record) and the output type (data for our file upload).

A word on symbols

Symbols are one of the more confusing concepts in PureScript because they look exactly like strings. This works from the compiler's point of view because strings are values whereas symbols are types, so it would never confuse a string for a symbol or vice versa. But the fact that they look alike can be confusing for us humans. What's more confusing is how something that looks like a string can be a type?

A string is just a list or array of characters with some sort of terminating "end-of-string" indicator. Similarly, a symbol is just a list of types with kind Char and some sort of terminating indicator.

If we wanted to build symbols from the ground up in PureScript, we would do:

foreign import kind Char

foreign import data D :: Char
foreign import data O :: Char
foreign import data G :: Char

foreign import kind Word

foreign import data ConsW :: Char -> Word -> Word
foreign import data EndW :: Word

type DOG = (ConsW D (ConsW O (ConsW G EndW)))
Enter fullscreen mode Exit fullscreen mode

Thankfully, the PureScript compiler reduces this boilerplate by allowing us to do:

type DOG = "DOG"
Enter fullscreen mode Exit fullscreen mode

So Symbol-s are just a list of characters with some terminal value, making each unique symbol a unique type.

Back to GraphQL

Now that I have my instances of GraphQL, I can create a function that sends the GraphQL to my backend (in this case, 8base).

data Gql (operation :: GraphQL)
  = Gql

graphQL :: forall (operation :: GraphQL) (gql :: Symbol) (i :: # Type) (o :: # Type). GraphQL operation gql i o => IsSymbol gql => JSON.WriteForeign { | i } => JSON.ReadForeign { | o } => Gql operation -> Record i -> Aff { | o }
graphQL _ variables = do
  endpoint <- liftEffect $ get8baseURL
  token <- get8baseToken
  let
    output =
      { variables
      , query: replaceAll (Pattern "\n") (Replacement " ") (replaceAll (Pattern "\r\n") (Replacement " ") (reflectSymbol (SProxy :: SProxy gql)))
      }
  res <-
    AX.request
      ( AX.defaultRequest
          { url = endpoint
          , method = Left POST
          , responseFormat = ResponseFormat.string
          , content =
            Just
              (RequestBody.string (JSON.writeJSON output))
          , headers =
            [ RequestHeader "Authorization" ("Bearer " <> token) ]
          }
      )
  case res of
    Left err -> do
      liftEffect $ Log.info "Request did not go through"
      throwError (error $ AX.printError err)
    Right response -> case (JSON.readJSON response.body) of
      Left err1 -> throwError (error $ ("Could not parse " <> show response.body <> " err " <> show err1))
      Right ({ data: d } :: { data :: { | o } }) -> pure d
Enter fullscreen mode Exit fullscreen mode

This function will work for any "row" from the GraphQL "table". Meaning we can do:

{ fileUploadInfo } <- graphQL (Gql :: Gql GetFileStackUploadInfo) {}
Enter fullscreen mode Exit fullscreen mode

and we can also do:

{ recordingUpdate } <-
      graphQL (Gql :: Gql VideoUpload)
        { id: input.recordingID
        , filename: fsResponse.filename
        , fileId
        }
Enter fullscreen mode Exit fullscreen mode

Both are typesafe: they will fail with anything other than the exact input you need and they will return nothing other than the exact input you expect to receive from your typeclass defintion.

An alternative to codegen

There are some great GraphQL codegen projects out there, and they definitely help reduce boilerplate. I personally prefer this approach because it pegs the output to the input. For example, checkout the codegen produced by graphql-codegen for the following graphql:

scalar Date

schema {
  query: Query
}

type Query {
  me: User!
  user(id: ID!): User
  allUsers: [User]
  search(term: String!): [SearchResult!]!
  myChats: [Chat!]!
}

enum Role {
  USER,
  ADMIN,
}

interface Node {
  id: ID!
}

union SearchResult = User | Chat | ChatMessage

type User implements Node {
  id: ID!
  username: String!
  email: String!
  role: Role!
}

type Chat implements Node {
  id: ID!
  users: [User!]!
  messages: [ChatMessage!]!
}

type ChatMessage implements Node {
  id: ID!
  content: String!
  time: Date!
  user: User!
}
Enter fullscreen mode Exit fullscreen mode

produces

export type Maybe<T> = T | null;
export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]?: Maybe<T[SubKey]> };
export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]: Maybe<T[SubKey]> };
/** All built-in and custom scalars, mapped to their actual values */
export type Scalars = {
  ID: string;
  String: string;
  Boolean: boolean;
  Int: number;
  Float: number;
  Date: any;
};


export type Query = {
  __typename?: 'Query';
  me: User;
  user?: Maybe<User>;
  allUsers?: Maybe<Array<Maybe<User>>>;
  search: Array<SearchResult>;
  myChats: Array<Chat>;
};


export type QueryUserArgs = {
  id: Scalars['ID'];
};


export type QuerySearchArgs = {
  term: Scalars['String'];
};

export enum Role {
  User = 'USER',
  Admin = 'ADMIN'
}

export type Node = {
  id: Scalars['ID'];
};

export type SearchResult = User | Chat | ChatMessage;

export type User = Node & {
  __typename?: 'User';
  id: Scalars['ID'];
  username: Scalars['String'];
  email: Scalars['String'];
  role: Role;
};

export type Chat = Node & {
  __typename?: 'Chat';
  id: Scalars['ID'];
  users: Array<User>;
  messages: Array<ChatMessage>;
};

export type ChatMessage = Node & {
  __typename?: 'ChatMessage';
  id: Scalars['ID'];
  content: Scalars['String'];
  time: Scalars['Date'];
  user: User;
};
Enter fullscreen mode Exit fullscreen mode

So if I query:

query { me { email } }
Enter fullscreen mode Exit fullscreen mode

What do I get back? In the typeclass solution, I get back something with the type { me :: { email :: String } }. In the typescript solution, I get back User. This leads to three interrelated problems:

  1. I have a lot more boilerplate now to check if the fields I want (ie email) are present.
  2. The object model is too permissive, which means I need to hand-write a type { email :: String } to enforce email's presence downstream, which defeats the purpose of codegen.
  3. If email is not served back for whatever reason, the runtime error is shifted far downstream to the point where we try to do something with it. In the typeclass solution, the failure happens directly at the point of query.

Note that this is no fault of graphql codegen (great project!). It's a limitation of typescript's type system and in general any system that does not have the ability to build up functional dependencies or dependent types.

Use typeclasses!

Typeclasses with functional dependencies are a graphql-programmer's dream-come-true. They allow you to create elegant simple relationships between graphql queries, input variables, and responses that are typesafe and predictable across your codebase. You can take typeclasses and functional dependencies for a spin in:

Discussion (0)