Custom scalar in GraphQL-ruby

GraphQL-ruby provides built-in scalar types like String, Int, Float, Boolean, and ID. However, sometimes you need custom scalar types to handle specific data formats like dates, JSON, or custom identifiers.

Why Custom Scalars?

Custom scalars are useful when you need to:

  • Handle dates and times in a specific format
  • Work with JSON data
  • Validate custom data formats
  • Transform data between client and server

Creating a Custom Scalar

Date Scalar Example

Let's create a custom Date scalar:

# app/graphql/types/date_type.rb
module Types
  class DateType < Types::BaseScalar
    description "A date value"

    def self.coerce_input(value, context)
      Date.parse(value)
    rescue ArgumentError
      raise GraphQL::CoercionError, "cannot coerce #{value.inspect} to Date"
    end

    def self.coerce_result(value, context)
      value.iso8601
    end
  end
end

Usage

Use your custom scalar in types and mutations:

# app/graphql/types/user_type.rb
module Types
  class UserType < Types::BaseObject
    field :id, ID, null: false
    field :name, String, null: false
    field :birthday, Types::DateType, null: true
  end
end

# app/graphql/mutations/create_user.rb
module Mutations
  class CreateUser < BaseMutation
    argument :name, String, required: true
    argument :birthday, Types::DateType, required: false

    field :user, Types::UserType, null: true

    def resolve(name:, birthday: nil)
      user = User.create(name: name, birthday: birthday)
      { user: user }
    end
  end
end

JSON Scalar

Create a scalar for JSON data:

# app/graphql/types/json_type.rb
module Types
  class JsonType < Types::BaseScalar
    description "A JSON value"

    def self.coerce_input(value, context)
      case value
      when String
        JSON.parse(value)
      when Hash, Array
        value
      else
        raise GraphQL::CoercionError, "cannot coerce #{value.inspect} to JSON"
      end
    end

    def self.coerce_result(value, context)
      value.to_json
    end
  end
end

Email Scalar

Create a scalar with validation:

# app/graphql/types/email_type.rb
module Types
  class EmailType < Types::BaseScalar
    description "A valid email address"

    EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i

    def self.coerce_input(value, context)
      if value.match?(EMAIL_REGEX)
        value
      else
        raise GraphQL::CoercionError, "#{value.inspect} is not a valid email"
      end
    end

    def self.coerce_result(value, context)
      value
    end
  end
end

URL Scalar

Create a scalar for URLs:

# app/graphql/types/url_type.rb
module Types
  class UrlType < Types::BaseScalar
    description "A valid URL"

    def self.coerce_input(value, context)
      uri = URI.parse(value)
      if uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
        value
      else
        raise GraphQL::CoercionError, "#{value.inspect} is not a valid URL"
      end
    rescue URI::InvalidURIError
      raise GraphQL::CoercionError, "#{value.inspect} is not a valid URL"
    end

    def self.coerce_result(value, context)
      value
    end
  end
end

DateTime Scalar

Create a DateTime scalar with timezone support:

# app/graphql/types/date_time_type.rb
module Types
  class DateTimeType < Types::BaseScalar
    description "A datetime value in ISO 8601 format"

    def self.coerce_input(value, context)
      Time.zone.parse(value)
    rescue ArgumentError
      raise GraphQL::CoercionError, "cannot coerce #{value.inspect} to DateTime"
    end

    def self.coerce_result(value, context)
      value.utc.iso8601
    end
  end
end

Money Scalar

Create a scalar for monetary values:

# app/graphql/types/money_type.rb
module Types
  class MoneyType < Types::BaseScalar
    description "A monetary value in cents"

    def self.coerce_input(value, context)
      case value
      when Integer
        value
      when Float
        (value * 100).to_i
      when String
        (Float(value) * 100).to_i
      else
        raise GraphQL::CoercionError, "cannot coerce #{value.inspect} to Money"
      end
    end

    def self.coerce_result(value, context)
      (value / 100.0).round(2)
    end
  end
end

Base Scalar Class

Create a base class for your scalars:

# app/graphql/types/base_scalar.rb
module Types
  class BaseScalar < GraphQL::Schema::Scalar
  end
end

Registering Scalars

Make sure your scalars are loaded. In your schema:

# app/graphql/your_schema.rb
class YourSchema < GraphQL::Schema
  # Scalars are automatically discovered if they're in Types::
end

Using Custom Scalars in Queries

Query with custom scalars:

query GetUser($birthday: Date!) {
  user(birthday: $birthday) {
    id
    name
    birthday
  }
}

Variables:

{
  "birthday": "1990-01-15"
}

Testing Custom Scalars

Test your custom scalars:

# spec/graphql/types/date_type_spec.rb
require 'rails_helper'

RSpec.describe Types::DateType do
  describe '.coerce_input' do
    it 'parses valid date strings' do
      result = described_class.coerce_input('2020-01-15', nil)
      expect(result).to eq(Date.parse('2020-01-15'))
    end

    it 'raises error for invalid dates' do
      expect {
        described_class.coerce_input('invalid', nil)
      }.to raise_error(GraphQL::CoercionError)
    end
  end

  describe '.coerce_result' do
    it 'formats dates as ISO 8601' do
      date = Date.parse('2020-01-15')
      result = described_class.coerce_result(date, nil)
      expect(result).to eq('2020-01-15')
    end
  end
end

Best Practices

  1. Validate input: Always validate input in coerce_input
  2. Consistent output: Use consistent formats in coerce_result
  3. Clear errors: Provide helpful error messages
  4. Documentation: Add descriptions to your scalars
  5. Reusability: Create base classes for common patterns

Common Patterns

Enum-like Scalar

module Types
  class StatusType < Types::BaseScalar
    VALID_VALUES = %w[active inactive pending].freeze

    def self.coerce_input(value, context)
      if VALID_VALUES.include?(value)
        value
      else
        raise GraphQL::CoercionError, "Status must be one of: #{VALID_VALUES.join(', ')}"
      end
    end

    def self.coerce_result(value, context)
      value
    end
  end
end

Conclusion

Custom scalars in GraphQL-ruby allow you to handle specific data types that aren't covered by built-in scalars. By implementing coerce_input and coerce_result, you can create type-safe, validated scalars that improve your GraphQL API's type system and provide better developer experience.

Custom scalar in GraphQL-ruby - Abhay Nikam