diff --git a/cspell.yml b/cspell.yml index 733048d696..ab32211f7c 100644 --- a/cspell.yml +++ b/cspell.yml @@ -25,6 +25,7 @@ overrides: - swcrc - noreferrer - xlink + - composability - deduplication ignoreRegExpList: diff --git a/website/pages/docs/_meta.ts b/website/pages/docs/_meta.ts index b766ad02f7..3c5bd7a419 100644 --- a/website/pages/docs/_meta.ts +++ b/website/pages/docs/_meta.ts @@ -19,6 +19,8 @@ const meta = { 'constructing-types': '', 'oneof-input-objects': '', 'defer-stream': '', + 'custom-scalars': '', + 'advanced-custom-scalars': '', 'n1-dataloader': '', 'resolver-anatomy': '', 'graphql-errors': '', diff --git a/website/pages/docs/advanced-custom-scalars.mdx b/website/pages/docs/advanced-custom-scalars.mdx new file mode 100644 index 0000000000..a7e7119a56 --- /dev/null +++ b/website/pages/docs/advanced-custom-scalars.mdx @@ -0,0 +1,203 @@ +--- +title: Best 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. + +### 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](https://www.iso.org/iso-8601-date-and-time-format.html) strings ending with `Z`. + +Clear descriptions help clients understand valid input and reduce mistakes. + +### Validate consistently across `parseValue` and `parseLiteral` + +Clients can send values either through variables or inline literals. +Your `parseValue` and `parseLiteral` functions should apply the same validation logic in +both cases. + +Use a shared helper to avoid duplication: + +```js +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 `parseValue` and `parseLiteral` 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: + +```text +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. + +```js +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`. +Test with both valid and invalid inputs. + +```js +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. + +```js +const { graphql, buildSchema } = require('graphql'); + +const schema = buildSchema(` + scalar DateTime + + type Query { + now: DateTime + } +`); + +const rootValue = { + now: () => new Date('2024-01-01T00:00:00Z'), +}; + +async function testQuery() { + const response = await graphql({ + schema, + source: '{ now }', + rootValue, + }); + 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. + +```js +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. + +```js +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`](https://www.npmjs.com/package/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: + +```js +const { EmailAddressResolver } = require('graphql-scalars'); + +const StrictEmail = new GraphQLScalarType({ + ...EmailAddressResolver, + parseValue(value) { + if (!value.endsWith('@example.com')) { + throw new TypeError('Only example.com emails are allowed.'); + } + return EmailAddressResolver.parseValue(value); + }, +}); +``` + +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 + +- [GraphQL Scalars by The Guild](https://the-guild.dev/graphql/scalars): A production-ready +library of common custom scalars. +- [GraphQL Scalars Specification](https://github.com/graphql/graphql-scalars): This +specification is no longer actively maintained, but useful for historical context. diff --git a/website/pages/docs/custom-scalars.mdx b/website/pages/docs/custom-scalars.mdx new file mode 100644 index 0000000000..b5d1959867 --- /dev/null +++ b/website/pages/docs/custom-scalars.mdx @@ -0,0 +1,126 @@ +--- +title: Using Custom Scalars +--- + +# Custom Scalars: When and How to Use Them + +In GraphQL, scalar types represent primitive data like strings, numbers, and booleans. +The GraphQL specification defines five built-in scalars: `Int`, `Float`, +`String`, `Boolean`, and `ID`. + +However, these default types don't cover all the formats or domain-specific values real-world +APIs often need. For example, you might want to represent a timestamp as an ISO 8601 string, or +ensure a user-submitted field is a valid email address. In these cases, you can define a custom +scalar type. + +In GraphQL.js, custom scalars are created using the `GraphQLScalarType` class. This gives you +full control over how values are serialized, parsed, and validated. + +Here’s a simple example of a custom scalar that handles date-time strings: + +```js +const { GraphQLScalarType, Kind } = require('graphql'); + +const DateTime = new GraphQLScalarType({ + name: 'DateTime', + description: 'An ISO-8601 encoded UTC date string.', + serialize(value) { + return value instanceof Date ? value.toISOString() : null; + }, + parseValue(value) { + return typeof value === 'string' ? new Date(value) : null; + }, + parseLiteral(ast) { + return ast.kind === Kind.STRING ? new Date(ast.value) : null; + }, +}); +``` +Custom scalars offer flexibility, but they also shift responsibility onto you. You're +defining not just the format of a value, but also how it is validated and how it moves +through your schema. + +This guide covers when to use custom scalars and how to define them in GraphQL.js. + +## When to use custom scalars + +Define a custom scalar when you need to enforce a specific format, encapsulate domain-specific +logic, or standardize a primitive value across your schema. For example: + +- Validation: Ensure that inputs like email addresses, URLs, or date strings match a +strict format. +- Serialization and parsing: Normalize how values are converted between internal and +client-facing formats. +- Domain primitives: Represent domain-specific values that behave like scalars, such as +UUIDs or currency codes. + +Common examples of useful custom scalars include: + +- `DateTime`: An ISO 8601 timestamp string +- `Email`: A syntactically valid email address +- `URL`: A well-formed web address +- `BigInt`: An integer that exceeds the range of GraphQL's built-in `Int` +- `UUID`: A string that follows a specific identifier format + +## When not to use a custom scalar + +Custom scalars are not a substitute for object types. Avoid using a custom scalar if: + +- The value naturally contains multiple fields or nested data (even if serialized as a string). +- Validation depends on relationships between fields or requires complex cross-checks. +- You're tempted to bypass GraphQL’s type system using a catch-all scalar like `JSON` or `Any`. + +Custom scalars reduce introspection and composability. Use them to extend GraphQL's scalar +system, not to replace structured types altogether. + +## How to define a custom scalar in GraphQL.js + +In GraphQL.js, a custom scalar is defined by creating an instance of `GraphQLScalarType`, +providing a name, description, and three functions: + +- `serialize`: How the server sends internal values to clients. +- `parseValue`: How the server parses incoming variable values. +- `parseLiteral`: How the server parses inline values in queries. + +The following example is a custom `DateTime` scalar that handles ISO-8601 encoded +date strings: + +```js +const { GraphQLScalarType, Kind } = require('graphql'); + +const DateTime = new GraphQLScalarType({ + name: 'DateTime', + description: 'An ISO-8601 encoded UTC date string.', + + serialize(value) { + if (!(value instanceof Date)) { + throw new TypeError('DateTime can only serialize Date instances'); + } + return value.toISOString(); + }, + + parseValue(value) { + const date = new Date(value); + if (isNaN(date.getTime())) { + throw new TypeError(`DateTime cannot represent an invalid date: ${value}`); + } + return date; + }, + + parseLiteral(ast) { + if (ast.kind !== Kind.STRING) { + throw new TypeError(`DateTime can only parse string values, but got: ${ast.kind}`); + } + const date = new Date(ast.value); + if (isNaN(date.getTime())) { + throw new TypeError(`DateTime cannot represent an invalid date: ${ast.value}`); + } + return date; + }, +}); +``` + +These functions give you full control over validation and data flow. + +## Learn more + +- [Custom Scalars: Best Practices and Testing](./advanced-custom-scalars): Dive deeper into validation, testing, and building production-grade custom scalars. \ No newline at end of file