Object.prototype.pipe() - Universal Method Chaining in JavaScript

Object.prototype.pipe() - Universal Method Chaining in JavaScript

Status

  • Stage: -1 (Strawperson)
  • Platform: es.discourse.group
  • Category: Proposals

Author

Fabio Roldan

Synopsis

A proposal to add a .pipe() method to Object.prototype enabling universal method chaining across all JavaScript values.

Motivation

JavaScript lacks a universal way to chain operations that works consistently across all types. While method chaining is common with objects and arrays, working with primitive values often requires nested function calls or temporary variables. This proposal aims to provide a uniform interface for value transformation that works with all JavaScript values.

Why Not Pipeline Operator?

The existing pipeline operator proposal (|>) introduces new syntax and requires parser changes. In contrast, .pipe():

  • Uses familiar method syntax
  • Works with existing code patterns
  • Can be polyfilled
  • Integrates naturally with existing method chains

Proposal

Object.prototype.pipe = function(callback) {
  if (typeof callback !== 'function') return this;
  
  const value = this instanceof Object && 
    (this instanceof Number || this instanceof Boolean || 
     this instanceof BigInt || this instanceof Symbol || 
     this instanceof String)
    ? this.valueOf()
    : this;
  
  const result = callback.call(this, value);
  return result !== undefined ? result : value;
};

Examples and Best Practices

:white_check_mark: Correct Usage Patterns

// String processing
"hello world    "
  .trim()
  .pipe(str => `*${str}*`);
  .toUpperCase()

// Array transformations
[1, 2, 3, 4, 5]
  .filter(n => n > 2)
  .pipe(arr => ({ sum: arr.reduce((a, b) => a + b, 0) }))
  .pipe(({ sum }) => sum.toString())
  .padStart(5, '0');

// Object manipulation
const user = { name: 'John', age: 30 }
  .pipe(obj => ({ ...obj, role: 'admin' }))
  .pipe(console.log)
  .pipe(({ role }) => role)
  .toUpperCase();

:x: Anti-patterns to Avoid

// Don't use pipe for simple native methods
value.pipe(str => str.toUpperCase())  // Instead: value.toUpperCase()
value.pipe(arr => arr.reverse())      // Instead: value.reverse()

// Don't chain multiple simple operations
value
  .pipe(x => x + 1)
  .pipe(x => x * 2)   // Instead: value.pipe(x => (x + 1) * 2)

Complex Data Processing

const processData = data => data
  .filter(isValid)
  .map(getValue)
  .pipe(values => ({
    total: values.reduce((sum, val) => sum + val, 0),
    count: values.length,
    items: values
  }))
  .pipe(stats => ({
    ...stats,
    average: stats.total / stats.count
  }));

Async Operations

const fetchAndProcess = async (data) => data
  .pipe(items => JSON.stringify(items))
  .pipe(async body => {
    const response = await fetch('/api/process', { 
      method: 'POST', 
      body 
    });
    return response.json();
  })
  .pipe(({ data, metadata }) => ({
    processed: data,
    timestamp: metadata.timestamp
  }));

Type-Safe Examples (TypeScript)

interface PipeableObject {
  pipe<T, R>(this: T, fn: (value: T) => R): R;
}

// Type inference examples
const processUser = (user: User) => user
  .pipe(({ name, age }) => ({ 
    displayName: name.toUpperCase(), 
    isAdult: age >= 18 
  }))
  .pipe(({ displayName, isAdult }) => ({
    message: `${displayName} is ${isAdult ? 'an adult' : 'underage'}`
  }));

Debugging

const debug = context => value => {
  console.log(`[${context}]`, {
    type: typeof value,
    value,
    timestamp: new Date().toISOString()
  });
  return value;
};

// Usage in data processing
const processWithDebug = data => data
  .pipe(debug('Initial data'))
  .filter(isValid)
  .pipe(debug('After validation'))
  .pipe(transform)
  .pipe(debug('Final result'));

Security and Error Handling

const processSecurely = userInput => userInput
  .pipe(input => {
    if (typeof input !== 'string') {
      throw new TypeError('Expected string input');
    }
    return input;
  })
  .pipe(sanitizeInput)
  .pipe(value => {
    try {
      return JSON.parse(value);
    } catch (e) {
      throw new ValidationError('Invalid JSON');
    }
  })
  .pipe(validateSchema)
  .pipe(auditLog);

Implementation Considerations

Performance

  1. Minimal overhead compared to traditional method chaining
  2. No parser modifications required
  3. JIT-friendly implementation
  4. Optimizable for common patterns

Edge Cases

// Primitive handling
undefined.pipe(x => x)        // returns undefined
null.pipe(x => x)            // returns null
new Number(42).pipe(x => x)  // returns 42

// Preserving this context
const obj = {
  value: 42,
  double() { return this.value * 2; }
};

obj.pipe(function() { 
  return this.double(); 
}); // returns 84

Framework Integration

// React Component
const DataProcessor = ({ rawData }) => {
  return rawData
    .pipe(normalizeData)
    .pipe(computeStats)
    .pipe(formatForDisplay)
    .map(item => (
      <DataItem key={item.id} {...item} />
    ));
};

// Vue Component
export default {
  computed: {
    processedData() {
      return this.rawData
        .pipe(this.normalize)
        .pipe(this.transform);
    }
  }
}

Future Considerations

  1. Promise handling optimizations
  2. Iterator/Generator support
  3. Performance improvements
  4. Additional debugging utilities

Discussion Points

Benefits

  1. Universal chaining across all types
  2. No new syntax required
  3. Natural integration with existing methods
  4. Improved debugging capabilities
  5. Easy to polyfill and test

Considerations

  1. Impact of extending Object.prototype
  2. Performance implications
  3. TypeScript integration
  4. Security considerations

Feedback Requested

  1. Is the syntax intuitive and clear?
  2. Are the use cases compelling?
  3. Are there edge cases we haven't considered?
  4. Concerns about extending Object.prototype?
  5. Implementation suggestions?
  6. Ideas for additional features?

Next Steps

  1. Gather community feedback
  2. Refine the proposal based on discussions
  3. Identify potential TC39 champions
  4. Prepare formal TC39 presentation

References


Please share your thoughts, concerns, and suggestions in the comments below.

2 Likes

It's a fun idea, but we are never going to add any new string-named methods to Object.prototype under any circumstances. The existing ones are a huge source of pain and it's absolutely certain that someone is doing something like if (foo.pipe) bar() in a way which this would break.

Additionally, null objects (including regex groups and import * as) wouldn’t have it.

1 Like

Yup, this idea is unfortunately dead in the water right from the get-go.

(Amusingly, the fact that we can't put things on Object.prototype, and thus have to do free functions like Object.foo(obj) instead, is a big part of the reasoning behind the pipe syntax proposal.)

3 Likes

Solutions can be implemented for specific technical challenges - I've included regex support in the implementation below as an example. However, I believe the most important aspect of this proposal is how using dot syntax feels more natural for the language. I would argue that this likely won't affect any libraries since they typically create their own objects. If needed, we could conduct an analysis on npm packages to verify this.

For null objects, we can handle them gracefully by checking for null/undefined before applying the pipe operation, similar to how optional chaining works. Here's a practical example with regex groups:

// Implementation
Object.defineProperty(Object.prototype, 'pipe', {
  enumerable: false,   // Won't show up in for...in loops
  configurable: false, // Can't be deleted/modified
  writable: true,     // Can't be overwritten
  value: function (callback) {
    if (typeof callback !== 'function') return this;

    // Get the actual value for primitives wrapped in objects
    const value = this instanceof Object &&
      (this instanceof Number || this instanceof Boolean ||
        this instanceof BigInt || this instanceof Symbol ||
        this instanceof String)
      ? this.valueOf()
      : this;

    const result = callback.call(this, value);
    return result !== undefined ? result : value;
  }
});

// Support for RegExp groups
RegExp.prototype.exec = (function (original) {
  return function (...args) {
    const result = original.apply(this, args);
    if (result && result.groups) {
      Object.defineProperty(result.groups, 'pipe', {
        value: Object.prototype.pipe,
        enumerable: false
      });
    }
    return result;
  };
})(RegExp.prototype.exec);

// Example usage with regex groups
const regex = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/;
const match = regex.exec('2024-01-30');
match?.groups?.pipe(groups => console.log(groups.year)); // Safely handles null cases

Thank you for your feedback!

1 Like

I understand that modifying Object.prototype is a sensitive topic. I proposed this mainly because dot notation feels incredibly natural and JavaScript has successfully introduced new methods to Object.prototype before.

When are you referring to?

Are you suggesting that we encourage everyone to hand-add a pipe method to every null-prototype object?

There's two reasons why that doesn't really work.

  1. It's not feasible to convince everyone who creates null prototype objects to add a pipe function to it. And in cases where we can't convince people, what do we do then?
  2. There's a reason these objects are created with a null prototype - because they specifically don't want them polluted with utility functions. These objects are being used more like a map data structure, and it would be weird to have a pipe function as one of the values in your map.

Null prototype objects isn't the only potential issue. Perhaps a more common issue - values that that could potentially be undefined or null - what if you want to pipe those?

1 Like

If you make this function strict then this conversion is not nessesary, this will still be the primitive

I appreciate your feedback and understand the concerns you've raised. You're absolutely right that encouraging everyone to manually add a pipe method to every null-prototype object isn't practical. Moreover, it goes against the purpose of creating objects with a null prototype, which is often to avoid inheriting properties and methods from Object.prototype.

The spirit of my proposal is less about the specific implementation and more about finding a way to achieve natural and universal method chaining in JavaScript. I recognize that modifying Object.prototype can lead to issues like name collisions and unintended side effects, especially with existing codebases that might rely on objects not having a pipe method.

I'm exploring whether there's a way to implement a piping mechanism that feels intuitive without requiring developers to wrap values explicitly or modify fundamental prototypes. For example, while we can create a helper like makePipeable('hello').pipe(str => ${str} world!), it introduces an extra layer that I'd prefer to avoid if possible.

Perhaps an alternative approach could involve a standalone pipe function or leveraging existing language features in a novel way. The goal is to enable seamless method chaining even with primitives, null, or undefined, without compromising the design principles of the language or existing practices.

I acknowledge the challenges involved, especially regarding values like null or undefined, and the potential for conflicts with null-prototype objects used as simple maps. I'm open to ideas and would love to engage in further discussion to find a solution that balances practicality with the desire for more natural code expression.

Thank you for highlighting these important considerations. Your input is invaluable in refining the proposal and aligning it more closely with the realities of JavaScript development.

There was this proposal, now withdrawn, for adding a pipe function: GitHub - tc39/proposal-function-pipe-flow: A proposal to standardize helper functions for serial function application and function composition.

I personally would be ok with either a pipe function or an operator.

Many libraries indeed use that approach, but personally, I'm not convinced by that syntax. I believe the dot notation looks better. I thought maybe someone could present an implementation that could resolve the arising issues, but it seems that's not the case. Thanks :+1:

Would this be acceptable?

Object.prototype[Symbol.pipe]

I don't see how that could cause issues. It is an interesting thought.

It would not immediately break everything the way adding a new string-named property would.

It's still not something we're likely to do because of how unwieldy it is and the fact that it doesn't work on null-prototype objects as mentioned above.

It also encourages creating lots of closures, which are a challenge to optimise for. The current pipeline proposal avoids this by being a purely syntactic transform with no hidden function calls