This content originally appeared on DEV Community and was authored by Marco Cheung
The Problem: When Convenience Becomes a Security Risk
The JavaScript spread operator (...
) is one of the most convenient features in modern JavaScript and TypeScript. It allows us to easily merge objects, clone arrays, and pass multiple arguments to functions. However, when it comes to constructing request payloads—especially for APIs with strict schemas like GraphQL—the spread operator can become a dangerous tool that introduces runtime errors and security vulnerabilities.
The Spread Operator Trap
What Seems Convenient...
Consider this common pattern when building API request payloads:
// ❌ Dangerous: Using spread operator for payload construction
const createUserPayload = {
...userFormData, // Contains: name, email, age, debugInfo, internalFlags
...additionalData, // Contains: preferences, metadata, tempId
...featureFlags, // Contains: enableBeta, debugMode, adminAccess
operation: 'CREATE_USER'
}
// Send to GraphQL API
const result = await graphqlClient.mutate({
mutation: CREATE_USER_MUTATION,
variables: { input: createUserPayload }
})
...Can Break in Production
# GraphQL Schema only accepts these fields:
input CreateUserInput {
name: String!
email: String!
age: Int
preferences: UserPreferences
operation: String!
}
Runtime Error:
GraphQL Error: Field "debugInfo" is not defined by type "CreateUserInput"
GraphQL Error: Field "internalFlags" is not defined by type "CreateUserInput"
GraphQL Error: Field "tempId" is not defined by type "CreateUserInput"
GraphQL Error: Field "debugMode" is not defined by type "CreateUserInput"
GraphQL Error: Field "adminAccess" is not defined by type "CreateUserInput"
Why TypeScript Can't Save You
Compile-Time Limitations
TypeScript's type checking has limitations when it comes to spread operators and dynamic object construction:
interface UserFormData {
name: string
email: string
age: number
// TypeScript doesn't know about these at compile time:
[key: string]: any // Index signature allows anything
}
interface CreateUserInput {
name: string
email: string
age?: number
operation: string
}
// ❌ TypeScript allows this, but it's dangerous
const payload: CreateUserInput = {
...userFormData, // May contain extra fields
operation: 'CREATE_USER'
}
// No compile-time error, but runtime failure guaranteed
The Index Signature Problem
// ❌ Common anti-pattern that defeats TypeScript's safety
interface FormData {
[key: string]: any // This disables type checking
}
// ❌ Or using 'any' type
const buildPayload = (data: any) => ({
...data,
timestamp: Date.now()
})
Real-World Attack Vectors
1. Debug Information Leakage
// ❌ Development debug info accidentally sent to production
const orderPayload = {
...orderData,
...debugInfo, // Contains: userId, sessionId, internalNotes
...{
// Development-only fields that shouldn't reach production
debugTimestamp: Date.now(),
developerNotes: 'Testing checkout flow',
internalCustomerId: 'dev_12345'
}
}
2. Privilege Escalation
// ❌ User input accidentally includes admin fields
const userInput = {
name: 'John Doe',
email: 'john@example.com',
// Malicious user adds these fields:
isAdmin: true,
permissions: ['DELETE_USERS', 'ACCESS_ADMIN_PANEL'],
role: 'SUPER_ADMIN'
}
const updateUserPayload = {
...userInput, // Blindly spreads potentially malicious data
updatedAt: new Date()
}
3. Schema Evolution Breakage
// ❌ Old code breaks when API schema changes
const legacyPayload = {
...oldUserData, // Contains deprecated fields
...newFeatureData, // Contains fields not yet in schema
...experimentalData // Contains A/B test fields
}
// API schema removed 'legacyField' and doesn't recognize 'experimentalField'
// Result: Runtime errors in production
Defensive Programming Solutions
1. Explicit Field Selection with Ramda's pick
import { pick } from 'ramda'
// ✅ Safe: Explicitly define allowed fields
const ALLOWED_USER_FIELDS = [
'name',
'email',
'age',
'preferences'
] as const
const createUserPayload = {
...pick(ALLOWED_USER_FIELDS, userFormData),
operation: 'CREATE_USER'
}
// Only specified fields are included, everything else is filtered out
2. Schema-Based Validation
// ✅ Define strict interfaces without index signatures
interface CreateUserInput {
readonly name: string
readonly email: string
readonly age?: number
readonly preferences?: UserPreferences
readonly operation: 'CREATE_USER'
}
// ✅ Builder pattern for safe construction
class UserPayloadBuilder {
private payload: Partial<CreateUserInput> = {}
setName(name: string): this {
this.payload.name = name
return this
}
setEmail(email: string): this {
this.payload.email = email
return this
}
setAge(age: number): this {
this.payload.age = age
return this
}
build(): CreateUserInput {
if (!this.payload.name || !this.payload.email) {
throw new Error('Name and email are required')
}
return {
name: this.payload.name,
email: this.payload.email,
age: this.payload.age,
preferences: this.payload.preferences,
operation: 'CREATE_USER'
}
}
}
// Usage
const payload = new UserPayloadBuilder()
.setName(userFormData.name)
.setEmail(userFormData.email)
.setAge(userFormData.age)
.build()
3. Functional Approach with Type Guards
// ✅ Type-safe field extraction
const extractUserFields = (data: unknown): CreateUserInput => {
if (!isValidUserData(data)) {
throw new Error('Invalid user data')
}
return {
name: data.name,
email: data.email,
age: data.age,
preferences: data.preferences,
operation: 'CREATE_USER'
}
}
// Type guard function
function isValidUserData(data: any): data is {
name: string
email: string
age?: number
preferences?: UserPreferences
} {
return (
typeof data === 'object' &&
typeof data.name === 'string' &&
typeof data.email === 'string' &&
(data.age === undefined || typeof data.age === 'number')
)
}
4. Whitelist-Based Object Construction
import { pick, omit } from 'ramda'
// ✅ Multiple layers of defense
const buildSafePayload = (rawData: Record<string, any>) => {
// Layer 1: Remove known dangerous fields
const withoutDangerousFields = omit([
'debugInfo',
'internalFlags',
'adminAccess',
'isAdmin',
'permissions',
'sessionId',
'authToken'
], rawData)
// Layer 2: Only include allowed fields
const allowedFields = pick([
'name',
'email',
'age',
'preferences'
], withoutDangerousFields)
// Layer 3: Explicit construction with validation
return {
name: String(allowedFields.name || '').trim(),
email: String(allowedFields.email || '').toLowerCase().trim(),
age: typeof allowedFields.age === 'number' ? allowedFields.age : undefined,
preferences: allowedFields.preferences || {},
operation: 'CREATE_USER' as const
}
}
Advanced Defensive Patterns
1. Runtime Schema Validation
Please refer to my another article
https://dev.to/marco_cheung_/client-side-graphql-variable-sanitization-preventing-runtime-errors-before-they-happen-kjn
Performance Considerations
Spread Operator Performance Impact
// ❌ Performance issues with large objects
const hugeObject = { /* 10,000 properties */ }
const anotherHugeObject = { /* 10,000 properties */ }
// Creates new object with 20,000 properties - expensive!
const combined = {
...hugeObject,
...anotherHugeObject,
newField: 'value'
}
// ✅ Better: Only pick what you need
const optimized = {
...pick(['field1', 'field2', 'field3'], hugeObject),
...pick(['field4', 'field5'], anotherHugeObject),
newField: 'value'
}
Memory Usage Comparison
// ❌ Memory inefficient
const createPayloads = (users: User[]) => {
return users.map(user => ({
...user, // Copies all user fields (potentially 50+ fields)
...user.profile, // Copies all profile fields (potentially 30+ fields)
...user.settings, // Copies all settings (potentially 20+ fields)
timestamp: Date.now()
}))
}
// ✅ Memory efficient
const createOptimizedPayloads = (users: User[]) => {
return users.map(user => ({
id: user.id,
name: user.name,
email: user.email,
avatar: user.profile?.avatar,
theme: user.settings?.theme,
timestamp: Date.now()
}))
}
Testing Defensive Patterns
1. Property-Based Testing
import fc from 'fast-check'
// ✅ Test with random malicious inputs
describe('Payload sanitization', () => {
it('should handle arbitrary malicious input', () => {
fc.assert(
fc.property(
fc.record({
// Valid fields
name: fc.string(),
email: fc.emailAddress(),
// Malicious fields
isAdmin: fc.boolean(),
deleteAllUsers: fc.boolean(),
__proto__: fc.anything(),
constructor: fc.anything()
}),
(maliciousInput) => {
const sanitized = buildSafePayload(maliciousInput)
// Should only contain allowed fields
expect(Object.keys(sanitized)).toEqual(['name', 'email', 'operation'])
// Should not contain dangerous fields
expect(sanitized).not.toHaveProperty('isAdmin')
expect(sanitized).not.toHaveProperty('deleteAllUsers')
expect(sanitized).not.toHaveProperty('__proto__')
}
)
)
})
})
2. Security-Focused Unit Tests
describe('Security tests', () => {
it('should prevent prototype pollution', () => {
const maliciousPayload = {
name: 'John',
email: 'john@example.com',
'__proto__': { isAdmin: true },
'constructor': { prototype: { isAdmin: true } }
}
const sanitized = buildSafePayload(maliciousPayload)
expect(sanitized.isAdmin).toBeUndefined()
expect(Object.prototype.isAdmin).toBeUndefined()
})
it('should handle deeply nested malicious objects', () => {
const deepMalicious = {
name: 'John',
preferences: {
theme: 'dark',
admin: {
deleteUsers: true,
accessLevel: 'SUPER_ADMIN'
}
}
}
const sanitized = buildSafePayload(deepMalicious)
expect(sanitized.preferences?.admin).toBeUndefined()
})
})
Conclusion
The spread operator is a powerful tool, but with great power comes great responsibility. When building request payloads for APIs—especially those with strict schemas like GraphQL—defensive programming practices are essential:
Key Takeaways:
-
Explicit is Better Than Implicit: Use
pick()
or explicit field selection instead of spreading entire objects - TypeScript Isn't Enough: Runtime validation is crucial for security
- Validate at Multiple Layers: Input validation, schema validation, and business logic validation
- Test with Malicious Input: Use property-based testing to catch edge cases
- Follow Zero Trust: Never trust input data, always validate and sanitize
The Golden Rule:
When in doubt, be explicit. It's better to write a few extra lines of code than to debug a production incident caused by unexpected fields in your API requests.
By following these defensive programming practices, you can build more robust, secure, and maintainable applications that gracefully handle the unexpected—because in software development, the unexpected is the only thing you can truly expect.
Remember: Security is not a feature you add later—it's a mindset you adopt from the beginning.
This content originally appeared on DEV Community and was authored by Marco Cheung

Marco Cheung | Sciencx (2025-08-21T13:27:33+00:00) Defensive Programming: The Hidden Dangers of Spread Operators in Request Payloads. Retrieved from https://www.scien.cx/2025/08/21/defensive-programming-the-hidden-dangers-of-spread-operators-in-request-payloads-2/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.