DocumentationBest Practices for Custom Scalars

Custom Scalars: Best Practices and Testing

Custom scalars must behave predictably and clearly. To maintain a consistent, reliable schema, follow these best practices.

GraphQL.js v17 renames the scalar hooks to the coercion terms used by the specification. The v16 names still work in v17, but are deprecated for removal in v18.

Map v16 names to v17 names

v16 namev17 namePurpose
serializecoerceOutputValueConvert resolver values into response values.
parseValuecoerceInputValueConvert variable values into internal values.
parseLiteralcoerceInputLiteralConvert constant GraphQL literals into internal values.
astFromValue()valueToLiteral()Convert external input values into GraphQL literals.

If your scalar supports both v16 and v17, keep the v16 method names for now. When your minimum version is v17, implement the v17 names and add valueToLiteral() if tooling needs to print defaults or external values for that scalar.

In v16, parseLiteral(ast, variables) could receive variable values directly. In v17, coerceInputLiteral() receives a constant literal. If a custom scalar tool needs to replace variables inside a value before calling that method, use the exported replaceVariables() helper first.

Document expected formats and validation

Provide a clear description of the scalar’s accepted input and output formats. For example, a DateTime scalar should explain that it expects ISO-8601 strings ending with Z.

Clear descriptions help clients understand valid input and reduce mistakes.

Validate consistently across value and literal coercion

Clients can send values either through variables or inline literals. Your value and literal coercion functions should apply the same validation logic in both cases. In v16 those functions are parseValue and parseLiteral; in v17 they are coerceInputValue and coerceInputLiteral.

Use a shared helper to avoid duplication:

function parseDate(value) {
  const date = new Date(value);
  if (isNaN(date.getTime())) {
    throw new TypeError(`DateTime cannot represent an invalid date: ${value}`);
  }
  return date;
}

Both input coercion paths should call this function.

Return clear errors

When validation fails, throw descriptive errors. Avoid generic messages like “Invalid input.” Instead, use targeted messages that explain the problem, such as:

DateTime cannot represent an invalid date: `abc123`

Clear error messages speed up debugging and make mistakes easier to fix.

Serialize consistently

Always serialize internal values into a predictable format. For example, a DateTime scalar should always produce an ISO string, even if its internal value is a Date object.

serialize(value) {
  if (!(value instanceof Date)) {
    throw new TypeError('DateTime can only serialize Date instances');
  }
  return value.toISOString();
}

Serialization consistency prevents surprises on the client side.

Testing custom scalars

Testing ensures your custom scalars work reliably with both valid and invalid inputs. Tests should cover three areas: coercion functions, schema integration, and error handling.

Unit test serialization and parsing

Write unit tests for each function: serialize, parseValue, and parseLiteral in v16, or coerceOutputValue, coerceInputValue, and coerceInputLiteral in v17. Test with both valid and invalid inputs.

describe('DateTime scalar', () => {
  it('serializes Date instances to ISO strings', () => {
    const date = new Date('2024-01-01T00:00:00Z');
    expect(DateTime.serialize(date)).toBe('2024-01-01T00:00:00.000Z');
  });
 
  it('throws if serializing a non-Date value', () => {
    expect(() => DateTime.serialize('not a date')).toThrow(TypeError);
  });
 
  it('parses ISO strings into Date instances', () => {
    const result = DateTime.parseValue('2024-01-01T00:00:00Z');
    expect(result).toBeInstanceOf(Date);
    expect(result.toISOString()).toBe('2024-01-01T00:00:00.000Z');
  });
 
  it('throws if parsing an invalid date string', () => {
    expect(() => DateTime.parseValue('invalid-date')).toThrow(TypeError);
  });
});

Test custom scalars in a schema

Integrate the scalar into a schema and run real GraphQL queries to validate end-to-end behavior.

import { graphql, GraphQLSchema, GraphQLObjectType } from 'graphql';
import { DateTimeResolver as DateTime } from 'graphql-scalars';
 
const Query = new GraphQLObjectType({
  name: 'Query',
  fields: {
    now: {
      type: DateTime,
      resolve() {
        return new Date();
      },
    },
  },
});
 
/*
  scalar DateTime
 
  type Query {
    now: DateTime
  }
*/
const schema = new GraphQLSchema({
  query: Query,
});
 
async function testQuery() {
  const response = await graphql({
    schema,
    source: '{ now }',
  });
  console.log(response);
}
 
testQuery();

Schema-level tests verify that the scalar behaves correctly during execution, not just in isolation.

Common use cases for custom scalars

Custom scalars solve real-world needs by handling types that built-in scalars don’t cover.

  • DateTime: Serializes and parses ISO-8601 date-time strings.
  • Email: Validates syntactically correct email addresses.
function validateEmail(value) {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  if (!emailRegex.test(value)) {
    throw new TypeError(`Email cannot represent invalid email address: ${value}`);
  }
  return value;
}
  • URL: Ensures well-formatted, absolute URLs.
function validateURL(value) {
  try {
    new URL(value);
    return value;
  } catch {
    throw new TypeError(`URL cannot represent an invalid URL: ${value}`);
  }
}
  • JSON: Represents arbitrary JSON structures, but use carefully because it bypasses GraphQL’s strict type checking.

When to use existing libraries

Writing scalars is deceptively tricky. Validation edge cases can lead to subtle bugs if not handled carefully.

Whenever possible, use trusted libraries like graphql-scalars. They offer production-ready scalars for DateTime, EmailAddress, URL, UUID, and many others.

Example: Handling email validation

Handling email validation correctly requires dealing with Unicode, quoted local parts, and domain validation. Rather than writing your own regex, it’s better to use a library scalar that’s already validated against standards.

If you need domain-specific behavior, you can wrap an existing scalar with custom rules:

import { EmailAddressResolver } from 'graphql-scalars';
 
const StrictEmailAddress = new GraphQLScalarType({
  ...EmailAddressResolver,
  name: 'StrictEmailAddress',
  parseValue(value) {
    const email = EmailAddressResolver.parseValue(value);
    if (!email.endsWith('@example.com')) {
      throw new TypeError('Only example.com emails are allowed.');
    }
    return email;
  },
  parseLiteral(literal, variables) {
    const email = EmailAddressResolver.parseLiteral(literal, variables);
    if (!email.endsWith('@example.com')) {
      throw new TypeError('Only example.com emails are allowed.');
    }
    return email;
  },
});

By following these best practices and using trusted tools where needed, you can build custom scalars that are reliable, maintainable, and easy for clients to work with.

Additional resources