logoChibiham
cover
🔀

Subject Position: Function Application Styles in OOP and FP

Introduction: The Feeling of "Performing calc on shape"

When writing code, we unconsciously place a "subject."

typescript
// OOP style
shape.area()      // "shape" returns its area

// FP style
area(shape)       // "area function" processes shape

Both perform the same calculation, yet they read differently. The former reads as "perform area on shape," while the latter reads as "area uses shape."

This difference isn't mere syntactic preference—it reflects how we conceptualize the relationship between data and operations, a fundamental paradigm distinction.

Receiver vs Argument: Where Knowledge Resides

In OOP, data (objects) "have" behavior.

typescript
class Circle {
  constructor(private radius: number) {}
  area(): number {
    return Math.PI * this.radius ** 2
  }
}

const circle = new Circle(5)
circle.area()  // circle knows its own area

In functional programming, functions "know" the data.

typescript
type Circle = { radius: number }

const area = (c: Circle): number => Math.PI * c.radius ** 2

area(circle)  // area function knows Circle's structure
AspectOOPFP
Where knowledge residesData has behaviorFunctions know data
Direction of extensionEasy to add new data typesEasy to add new operations
Unit of cohesionClass (data + operations)Module (types + functions)

This is the essence of the Expression Problem.

Expression Problem: The Extension Dilemma

The "direction of extension" difference shown in the table above corresponds to a classic problem known as the Expression Problem.

The Core Issue

For the question "Can we extend both data types and operations without modifying existing code?", OOP and FP provide different answers.

typescript
// OOP: Adding new data types is easy
class Triangle extends Shape {
  area(): number { ... }  // Add Triangle without changing existing code
}

// However, adding new operations is difficult
// → Need to add perimeter() to all classes
typescript
// FP: Adding new operations is easy
const perimeter = (s: Shape): number => ...  // Just add a new function

// However, adding new data types is difficult
// → Need to add Triangle case to all functions

Which to Choose?

This isn't about "which is better"—it's a design decision that predicts the direction of domain changes.

Expected ChangesSuitable Style
More types of data will be addedOOP (add classes)
More operations/processing will be addedFP (add functions)
Both will grow equallyVisitor, extension functions, etc.

For an e-commerce site, if "product types" will increase, design OOP-style; if "analytics and reporting features" will increase, design FP-style for easier changes.

Pipe Operator: Data-First Writing in FP

Many functional languages enable data-first notation through pipe operators.

elixir
# Elixir
shape |> area() |> format()
fsharp
// F#
shape |> area |> format
haskell
-- Haskell (& operator from Data.Function)
shape & area & format

These read almost identically to shape.area().format(): "perform area on shape, then perform format on the result."

Reference: Haskell Styles

NotationDirectionUse Case
format (area shape)inside→outPlain application
format $ area $ shaperight→leftParenthesis elimination
shape & area & formatleft→rightData flow
format . arearight→leftFunction composition

In the Haskell community, . and $ are mainstream—the culture favors "assembling functions" over "flowing data."

Kotlin: Extension Functions as a Middle Ground

Kotlin lacks a pipe operator but incorporates the best of both worlds through extension functions.

kotlin
// Extension function: defined outside the class, callable with method syntax
fun Shape.area(): Double = when (this) {
    is Circle -> PI * radius * radius
    is Rectangle -> width * height
}

shape.area()  // OOP-style syntax

Additionally, scope functions enable pipe-like notation:

kotlin
shape
    .let { calculateArea(it) }
    .let { formatResult(it) }

Extension function characteristics:

  • Syntax is shape.area() (OOP-style, has receiver)
  • Definition is outside the class (FP-style, operations can be added later)
  • Eases the "adding operations" side of the Expression Problem while maintaining receiver syntax

TypeScript: Choose Your Style

TypeScript lacks a pipe operator (Stage 2 proposal at TC39), but libraries provide similar functionality.

Managing Types and Functions in the Same File

typescript
// domain/shape.ts
type Shape =
  | { type: "circle"; radius: number }
  | { type: "rectangle"; width: number; height: number }

const area = (s: Shape): number =>
  s.type === "circle"
    ? Math.PI * s.radius ** 2
    : s.width * s.height

const format = (n: number): string => `${n.toFixed(2)}㎡`

export { Shape, area, format }

This effectively achieves module = type + operations set cohesion. You create the same structure as an OOP class using discriminated unions and functions.

Data-First (Left to Right)

typescript
import { pipe } from "fp-ts/function"

pipe(shape, area, format)
// shape → area → format
// "perform area on shape, then format"

Function Composition (Right-to-Left Definition, Later Application)

typescript
import { flow } from "fp-ts/function"

const formatArea = flow(area, format)
// Compose area → format into a new function
formatArea(shape)

Plain Nesting

typescript
format(area(shape))
// Data flows from inside to outside
StyleWriting ApproachBest For
pipe(shape, area, format)Data flowsOne-time transformations
flow(area, format)Assemble functionsReusable transformations
format(area(shape))MathematicalSimple composition

Starting Point of Thought: Data or Operations?

Whether "perform area on shape" feels natural or "area processes shape" feels natural reflects a different starting point for thinking.

Data-First Thinking

  • "What can I do with this data?"
  • Type shape. in IDE to see available operations
  • OOP-style, Kotlin extension functions, TypeScript's pipe

Operation-First Thinking

  • "What can this operation be applied to?"
  • Look at function type signatures like Shape -> number
  • FP-style, Haskell function composition

Neither is correct—it's about choosing based on the problem domain and personal thinking style.

Practical Guidelines

A practical approach when using discriminated unions in TypeScript:

  1. Place types and functions in the same file to increase cohesion
  2. For one-time transformations, use pipe for data-first writing
  3. For reusable transformations, use flow to compose functions
  4. For simple cases, plain nesting is sufficient
typescript
// domain/order.ts
type Order = Draft | Paid | Shipped

const ship = (o: Paid): Shipped => { ... }
const cancel = (o: Draft | Paid): Cancelled => { ... }
const toSummary = (o: Order): string => { ... }

export { Order, ship, cancel, toSummary }

This file structure consolidates "what can be done with Order type" in one place. Syntactically they're functions, but conceptually they have the same cohesion as an OOP class.

Conclusion: Design Consistency Beyond Syntax

Rather than an OOP vs functional binary opposition, what matters is that the same design principles apply regardless of which syntax you use.

  • Cohesion: Place related data and operations close together
  • Coupling: Minimize dependencies between modules
  • Expression Problem: Design with awareness of extension direction

"Subject position" is a syntactic choice; the underlying design philosophy—the relationship between data and operations, scope of change propagation, module boundaries—is shared.

Choose syntax that fits your thinking style while maintaining design consistency. That's a practical approach that transcends paradigms.