36

Is it possible to specify that a field in GraphQL should be a blackbox, similar to how Flow has an "any" type? I have a field in my schema that should be able to accept any arbitrary value, which could be a String, Boolean, Object, Array, etc.

Eesa
  • 2,288
  • 2
  • 24
  • 46
Jon Cursi
  • 2,837
  • 3
  • 19
  • 48

6 Answers6

35

I've come up with a middle-ground solution. Rather than trying to push this complexity onto GraphQL, I'm opting to just use the String type and JSON.stringifying my data before setting it on the field. So everything gets stringified, and later in my application when I need to consume this field, I JSON.parse the result to get back the desired object/array/boolean/ etc.

Jon Cursi
  • 2,837
  • 3
  • 19
  • 48
19

@mpen's answer is great, but I opted for a more compact solution:

const { GraphQLScalarType } = require('graphql')
const { Kind } = require('graphql/language')

const ObjectScalarType = new GraphQLScalarType({
  name: 'Object',
  description: 'Arbitrary object',
  parseValue: (value) => {
    return typeof value === 'object' ? value
      : typeof value === 'string' ? JSON.parse(value)
      : null
  },
  serialize: (value) => {
    return typeof value === 'object' ? value
      : typeof value === 'string' ? JSON.parse(value)
      : null
  },
  parseLiteral: (ast) => {
    switch (ast.kind) {
      case Kind.STRING: return JSON.parse(ast.value)
      case Kind.OBJECT: throw new Error(`Not sure what to do with OBJECT for ObjectScalarType`)
      default: return null
    }
  }
})

Then my resolvers looks like:

{
  Object: ObjectScalarType,
  RootQuery: ...
  RootMutation: ...
}

And my .gql looks like:

scalar Object

type Foo {
  id: ID!
  values: Object!
}
a paid nerd
  • 28,574
  • 30
  • 121
  • 167
11

Yes. Just create a new GraphQLScalarType that allows anything.

Here's one I wrote that allows objects. You can extend it a bit to allow more root types.

import {GraphQLScalarType} from 'graphql';
import {Kind} from 'graphql/language';
import {log} from '../debug';
import Json5 from 'json5';

export default new GraphQLScalarType({
    name: "Object",
    description: "Represents an arbitrary object.",
    parseValue: toObject,
    serialize: toObject,
    parseLiteral(ast) {
        switch(ast.kind) {
            case Kind.STRING:
                return ast.value.charAt(0) === '{' ? Json5.parse(ast.value) : null;
            case Kind.OBJECT:
                return parseObject(ast);
        }
        return null;
    }
});

function toObject(value) {
    if(typeof value === 'object') {
        return value;
    }
    if(typeof value === 'string' && value.charAt(0) === '{') {
        return Json5.parse(value);
    }
    return null;
}

function parseObject(ast) {
    const value = Object.create(null);
    ast.fields.forEach((field) => {
        value[field.name.value] = parseAst(field.value);
    });
    return value;
}

function parseAst(ast) {
    switch (ast.kind) {
        case Kind.STRING:
        case Kind.BOOLEAN:
            return ast.value;
        case Kind.INT:
        case Kind.FLOAT:
            return parseFloat(ast.value);
        case Kind.OBJECT: 
            return parseObject(ast);
        case Kind.LIST:
            return ast.values.map(parseAst);
        default:
            return null;
    }
}
mpen
  • 237,624
  • 230
  • 766
  • 1,119
6

For most use cases, you can use a JSON scalar type to achieve this sort of functionality. There's a number of existing libraries you can just import rather than writing your own scalar -- for example, graphql-type-json.

If you need a more fine-tuned approach, than you'll want to write your own scalar type. Here's a simple example that you can start with:

const { GraphQLScalarType, Kind } = require('graphql')
const Anything = new GraphQLScalarType({
  name: 'Anything',
  description: 'Any value.',
  parseValue: (value) => value,
  parseLiteral,
  serialize: (value) => value,
})

function parseLiteral (ast) {
  switch (ast.kind) {
    case Kind.BOOLEAN:
    case Kind.STRING:  
      return ast.value
    case Kind.INT:
    case Kind.FLOAT:
      return Number(ast.value)
    case Kind.LIST:
      return ast.values.map(parseLiteral)
    case Kind.OBJECT:
      return ast.fields.reduce((accumulator, field) => {
        accumulator[field.name.value] = parseLiteral(field.value)
        return accumulator
      }, {})
    case Kind.NULL:
        return null
    default:
      throw new Error(`Unexpected kind in parseLiteral: ${ast.kind}`)
  }
}

Note that scalars are used both as outputs (when returned in your response) and as inputs (when used as values for field arguments). The serialize method tells GraphQL how to serialize a value returned in a resolver into the data that's returned in the response. The parseLiteral method tells GraphQL what to do with a literal value that's passed to an argument (like "foo", or 4.2 or [12, 20]). The parseValue method tells GraphQL what to do with the value of a variable that's passed to an argument.

For parseValue and serialize we can just return the value we're given. Because parseLiteral is given an AST node object representing the literal value, we have to do a little bit of work to convert it into the appropriate format.

You can take the above scalar and customize it to your needs by adding validation logic as needed. In any of the three methods, you can throw an error to indicate an invalid value. For example, if we want to allow most values but don't want to serialize functions, we can do something like:

if (typeof value == 'function') {
  throw new TypeError('Cannot serialize a function!')
}
return value

Using the above scalar in your schema is simple. If you're using vanilla GraphQL.js, then use it just like you would any of the other scalar types (GraphQLString, GraphQLInt, etc.) If you're using Apollo, you'll need to include the scalar in your resolver map as well as in your SDL:

const resolvers = {
  ...
  // The property name here must match the name you specified in the constructor
  Anything,
}

const typeDefs = `
  # NOTE: The name here must match the name you specified in the constructor
  scalar Anything

  # the rest of your schema
`
Daniel Rearden
  • 58,313
  • 8
  • 105
  • 113
3

Just send a stringified value via GraphQL and parse it on the other side, e.g. use this wrapper class.

export class Dynamic {

    @Field(type => String)
    private value: string;

    getValue(): any {
        return JSON.parse(this.value);
    }

    setValue(value: any) {
        this.value = JSON.stringify(value);
    }
}
laprof
  • 826
  • 2
  • 5
  • 19
0

For similar problem I've created schema like this:

"""`MetadataEntry` model"""
type MetadataEntry {
  """Key of the entry"""
  key: String!

  """Value of the entry"""
  value: String!
}

"""Object with metadata"""
type MyObjectWithMetadata {

  """
  ... rest of my object fields
  """

  """
  Key-value entries that you can attach to an object. This can be useful for
  storing additional information about the object in a structured format
  """
  metadata: [MetadataEntry!]!

  """Returns value of `MetadataEntry` for given key if it exists"""
  metadataValue(
    """`MetadataEntry` key"""
    key: String!
  ): String
}

And my queries can look like this:

query {
  listMyObjects {
    # fetch meta values by key
    meta1Value: metadataValue(key: "meta1")
    meta2Value: metadataValue(key: "meta2")
    # ... or list them all
    metadata {
      key
      value
    }
  }
}
kaznovac
  • 768
  • 8
  • 21