DEV Community

Marco Cheung
Marco Cheung

Posted on

Client-Side GraphQL Variable Sanitization: Preventing Runtime Errors Before They Happen

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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'
}
Enter fullscreen mode Exit fullscreen mode

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
)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 
  }
)
Enter fullscreen mode Exit fullscreen mode

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()
  })
})
Enter fullscreen mode Exit fullscreen mode

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()
  })
})
Enter fullscreen mode Exit fullscreen mode

Conclusion

Client-side GraphQL variable sanitization is a powerful technique that provides multiple benefits:

  1. Prevents Runtime Errors: Eliminates GraphQL errors caused by invalid fields
  2. Enhances Developer Experience: Automatic cleanup without manual intervention
  3. 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)