The Problem: When Valid Code Breaks Production
Picture this scenario: You're working on an e-commerce application, and your frontend is sending a GraphQL mutation to create a product order. Everything works perfectly in development, but suddenly production starts throwing errors:
GraphQL Error: Field "debugTimestamp" is not defined by type "OrderInput"
GraphQL Error: Field "internalFlags" is not defined by type "CustomerInput"
These errors occur because your client code is sending extra fields that don't exist in your GraphQL schema. Maybe they were added during debugging, came from feature flags, or were leftover from a previous API version. Regardless of the cause, these invalid fields are breaking your production GraphQL calls.
The Solution: Client-Side Variable Sanitization
Rather than playing whack-a-mole with invalid fields, we can implement a robust client-side sanitization system that automatically removes unknown fields before sending GraphQL requests. This approach provides several key benefits:
🛡️ Reliability Benefits
- Prevents Runtime Errors: Invalid fields are caught before reaching the server
- Backward Compatibility: Gracefully handles API version mismatches
- Debug-Safe Production: Debug fields are automatically stripped in production
🔧 Developer Experience Benefits
- Automatic Cleanup: No manual field management required
- Type Safety: Works seamlessly with TypeScript
- Zero Configuration: Set it up once, works everywhere
Implementation Strategy
Step 1: Generate Schema Metadata with GraphQL Codegen
First, we use GraphQL Code Generator to create a runtime-accessible schema map. This gives us a lightweight, performant way to validate fields at runtime.
codegen.yml:
schema: 'https://api.example.com/graphql'
generates:
# Generate TypeScript types
src/types/graphql-types.ts:
plugins:
- typescript
- typescript-operations
config:
enumsAsTypes: true
skipTypename: true
# Generate schema introspection for runtime validation
src/utils/schema-metadata.json:
plugins:
- introspection
config:
minify: true
generateSchemaMap.ts:
import fs from 'fs'
import path from 'path'
import { IntrospectionSchema, IntrospectionInputObjectType } from 'graphql'
interface SchemaMap {
[inputType: string]: {
[fieldName: string]: string
}
}
function generateSchemaMap(introspectionData: IntrospectionSchema): SchemaMap {
const schemaMap: SchemaMap = {}
introspectionData.types
.filter((type): type is IntrospectionInputObjectType =>
type.kind === 'INPUT_OBJECT'
)
.forEach((inputType) => {
schemaMap[inputType.name] = {}
inputType.inputFields.forEach((field) => {
schemaMap[inputType.name][field.name] = field.type.toString()
})
})
return schemaMap
}
// Generate the runtime schema map
const introspectionPath = path.join(__dirname, 'schema-metadata.json')
const introspectionData = JSON.parse(fs.readFileSync(introspectionPath, 'utf-8'))
const schemaMap = generateSchemaMap(introspectionData.data)
// Export as a TypeScript module
const outputContent = `// Auto-generated schema map for runtime validation
// Generated on: ${new Date().toISOString()}
// DO NOT EDIT MANUALLY
export const schemaMap: Record<string, Record<string, string>> = ${JSON.stringify(schemaMap, null, 2)}
`
fs.writeFileSync(path.join(__dirname, 'schemaMap.ts'), outputContent)
This generates a lightweight schema map like:
schemaMap.ts:
export const schemaMap: Record<string, Record<string, string>> = {
"OrderInput": {
"productId": "String!",
"quantity": "Int!",
"customerInfo": "CustomerInput!",
"paymentInfo": "PaymentInput"
},
"CustomerInput": {
"firstName": "String!",
"lastName": "String!",
"email": "String!",
"phone": "String"
},
"PaymentInput": {
"creditCardNumber": "String!",
"expiryDate": "String!",
"cvv": "String!",
"billingAddress": "AddressInput"
}
}
Step 2: Implement the Sanitization Function
Now we create a sanitization function that recursively cleans GraphQL variables:
sanitizeGraphQLVariables.ts:
import { schemaMap } from './schemaMap'
interface SanitizationOptions {
enableLogging?: boolean
strictMode?: boolean
}
interface SanitizationResult<T> {
sanitizedVariables: T
removedFields: string[]
warnings: string[]
}
export function sanitizeGraphQLVariables<T extends Record<string, any>>(
variables: T,
operation: string,
options: SanitizationOptions = {}
): SanitizationResult<T> {
const { enableLogging = false, strictMode = false } = options
const removedFields: string[] = []
const warnings: string[] = []
function sanitizeObject(
obj: any,
schemaTypeName: string,
path: string = ''
): any {
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
return obj
}
const schema = schemaMap[schemaTypeName]
if (!schema) {
if (strictMode) {
warnings.push(`Unknown schema type: ${schemaTypeName}`)
}
return obj
}
const sanitized: any = {}
Object.keys(obj).forEach((key) => {
const fieldPath = path ? `${path}.${key}` : key
if (schema[key]) {
// Field exists in schema, process it
const fieldType = schema[key]
const value = obj[key]
if (value !== null && value !== undefined) {
// Handle nested objects
const nestedTypeName = extractTypeName(fieldType)
if (schemaMap[nestedTypeName]) {
sanitized[key] = sanitizeObject(value, nestedTypeName, fieldPath)
} else {
sanitized[key] = value
}
} else {
sanitized[key] = value
}
} else {
// Field doesn't exist in schema, remove it
removedFields.push(fieldPath)
if (enableLogging) {
console.warn(`Removed invalid field: ${fieldPath} from ${schemaTypeName}`)
}
}
})
return sanitized
}
function extractTypeName(fieldType: string): string {
// Remove GraphQL type modifiers (!, [])
return fieldType.replace(/[!\[\]]/g, '')
}
// Determine root input type from operation
const rootTypeName = inferRootTypeName(operation)
const sanitizedVariables = sanitizeObject(variables, rootTypeName) as T
return {
sanitizedVariables,
removedFields,
warnings
}
}
function inferRootTypeName(operation: string): string {
// Simple heuristic to determine input type from operation name
if (operation.includes('createOrder')) return 'OrderInput'
if (operation.includes('updateCustomer')) return 'CustomerInput'
if (operation.includes('processPayment')) return 'PaymentInput'
// Default fallback
return 'GenericInput'
}
Step 3: Integrate with GraphQL Client
Finally, we integrate the sanitization into our GraphQL client:
graphqlClient.ts:
import { GraphQLClient } from 'graphql-request'
import { sanitizeGraphQLVariables } from './sanitizeGraphQLVariables'
interface GraphQLRequestOptions {
enableSanitization?: boolean
sanitizationOptions?: SanitizationOptions
}
class EnhancedGraphQLClient {
private client: GraphQLClient
private enableSanitization: boolean
constructor(endpoint: string, enableSanitization: boolean = true) {
this.client = new GraphQLClient(endpoint)
this.enableSanitization = enableSanitization
}
async request<T = any>(
query: string,
variables?: any,
options: GraphQLRequestOptions = {}
): Promise<T> {
let finalVariables = variables
if (this.enableSanitization && variables) {
const operationName = this.extractOperationName(query)
const result = sanitizeGraphQLVariables(
variables,
operationName,
options.sanitizationOptions
)
finalVariables = result.sanitizedVariables
// Log removed fields in development
if (process.env.NODE_ENV === 'development' && result.removedFields.length > 0) {
console.group(`🧹 GraphQL Sanitization - ${operationName}`)
console.warn('Removed fields:', result.removedFields)
console.warn('Original variables:', variables)
console.warn('Sanitized variables:', finalVariables)
console.groupEnd()
}
}
return this.client.request<T>(query, finalVariables)
}
private extractOperationName(query: string): string {
const match = query.match(/(?:mutation|query)\s+(\w+)/)
return match ? match[1] : 'UnknownOperation'
}
}
export const graphqlClient = new EnhancedGraphQLClient(
process.env.GRAPHQL_ENDPOINT!,
process.env.NODE_ENV === 'production' // Enable in production
)
Real-World Usage Examples
Example 1: Product Order with Debug Data
Before Sanitization:
const createOrderMutation = `
mutation createProductOrder($input: OrderInput!) {
createOrder(input: $input) {
id
orderNumber
status
}
}
`
// This payload contains invalid debug fields
const variables = {
input: {
productId: "prod_123",
quantity: 1,
customerInfo: {
firstName: "John",
lastName: "Doe",
email: "john@example.com",
phone: "+1234567890",
// ❌ Invalid fields that would break the GraphQL call
debugUserId: "debug_123",
internalFlags: ["testing", "debug"]
},
paymentInfo: {
creditCardNumber: "4111111111111111",
expiryDate: "12/25",
cvv: "123",
// ❌ Invalid field
debugTransactionId: "debug_txn_456"
},
// ❌ Invalid field
requestTimestamp: Date.now()
}
}
// This would throw an error without sanitization
const result = await graphqlClient.request(createOrderMutation, variables)
After Sanitization:
// The sanitizer automatically removes invalid fields:
// ✅ Removed: input.customerInfo.debugUserId
// ✅ Removed: input.customerInfo.internalFlags
// ✅ Removed: input.paymentInfo.debugTransactionId
// ✅ Removed: input.requestTimestamp
// Final sanitized payload sent to server:
{
input: {
productId: "prod_123",
quantity: 1,
customerInfo: {
firstName: "John",
lastName: "Doe",
email: "john@example.com",
phone: "+1234567890"
},
paymentInfo: {
creditCardNumber: "4111111111111111",
expiryDate: "12/25",
cvv: "123"
}
}
}
Example 2: Feature Flag Integration
// Feature-flagged fields are automatically handled
const updateCustomerMutation = `
mutation updateCustomerProfile($input: CustomerInput!) {
updateCustomer(input: $input) {
id
profile {
firstName
lastName
email
}
}
}
`
const variables = {
input: {
firstName: "Jane",
lastName: "Smith",
email: "jane@example.com",
// This field might exist in some API versions but not others
...(featureFlags.enableLoyaltyProgram && {
loyaltyMemberId: "loyalty_123"
}),
// Debug fields that should never reach production
...(process.env.NODE_ENV === 'development' && {
debugNotes: "Testing customer update",
developerInfo: { userId: "dev_user_123" }
})
}
}
// Sanitization ensures only valid fields are sent
const result = await graphqlClient.request(updateCustomerMutation, variables)
Feature Flags and Environment Support
You can easily integrate sanitization with feature flags:
featureFlaggedSanitization.ts:
interface FeatureFlags {
enableGraphQLSanitization: boolean
strictModeValidation: boolean
enableSanitizationLogging: boolean
}
export function createSanitizationOptions(featureFlags: FeatureFlags): SanitizationOptions {
return {
enableLogging: featureFlags.enableSanitizationLogging &&
process.env.NODE_ENV === 'development',
strictMode: featureFlags.strictModeValidation
}
}
// Usage in GraphQL client
const sanitizationEnabled = featureFlags.enableGraphQLSanitization
const sanitizationOptions = createSanitizationOptions(featureFlags)
const result = await graphqlClient.request(
query,
variables,
{
enableSanitization: sanitizationEnabled,
sanitizationOptions
}
)
Testing Strategy
Unit Tests for Sanitization
describe('GraphQL Variable Sanitization', () => {
it('should remove invalid fields from nested objects', () => {
const input = {
validField: 'keep me',
invalidField: 'remove me',
nestedObject: {
validNested: 'keep me too',
invalidNested: 'remove me too'
}
}
const result = sanitizeGraphQLVariables(input, 'TestInput')
expect(result.sanitizedVariables).toEqual({
validField: 'keep me',
nestedObject: {
validNested: 'keep me too'
}
})
expect(result.removedFields).toEqual([
'invalidField',
'nestedObject.invalidNested'
])
})
it('should handle arrays and null values correctly', () => {
const input = {
validArray: [{ validField: 'test' }],
nullValue: null,
undefinedValue: undefined
}
const result = sanitizeGraphQLVariables(input, 'TestInput')
expect(result.sanitizedVariables.nullValue).toBeNull()
expect(result.sanitizedVariables.undefinedValue).toBeUndefined()
})
})
Integration Tests
describe('GraphQL Client Integration', () => {
it('should successfully create order after sanitization', async () => {
const dirtyVariables = {
// Include both valid and invalid fields
input: { ...validOrderData, ...invalidDebugFields }
}
// Should not throw an error despite invalid fields
const result = await graphqlClient.request(
CREATE_ORDER_MUTATION,
dirtyVariables
)
expect(result.createOrder.id).toBeDefined()
expect(result.createOrder.orderNumber).toBeDefined()
})
})
Conclusion
Client-side GraphQL variable sanitization is a powerful technique that provides multiple benefits:
- Prevents Runtime Errors: Eliminates GraphQL errors caused by invalid fields
- Enhances Developer Experience: Automatic cleanup without manual intervention
- Increases Reliability: Makes your application more resilient to API changes
By leveraging GraphQL Code Generator to create runtime schema maps and implementing a lightweight sanitization layer, you can build more robust and performant GraphQL applications.
The key to success is implementing this as an automatic, zero-configuration system that works seamlessly with your existing GraphQL client. Once set up, it provides ongoing protection against field-related errors while optimizing your network requests.
Implementation Checklist
- [ ] Set up GraphQL Code Generator with introspection plugin
- [ ] Generate runtime schema map from introspection data
- [ ] Implement sanitization function with proper TypeScript types
- [ ] Integrate with your GraphQL client
- [ ] Add feature flag support for different environments
- [ ] Write comprehensive unit and integration tests
- [ ] Monitor performance impact and error reduction
- [ ] Document the system for your team
Start with a simple implementation and gradually add more sophisticated features like strict mode validation, detailed logging, and advanced type inference. Your future self (and your production error logs) will thank you!
Top comments (0)