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."
// OOP style
shape.area() // "shape" returns its area
// FP style
area(shape) // "area function" processes shapeBoth 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.
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 areaIn functional programming, functions "know" the data.
type Circle = { radius: number }
const area = (c: Circle): number => Math.PI * c.radius ** 2
area(circle) // area function knows Circle's structure| Aspect | OOP | FP |
|---|---|---|
| Where knowledge resides | Data has behavior | Functions know data |
| Direction of extension | Easy to add new data types | Easy to add new operations |
| Unit of cohesion | Class (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.
// 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// 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 functionsWhich to Choose?
This isn't about "which is better"—it's a design decision that predicts the direction of domain changes.
| Expected Changes | Suitable Style |
|---|---|
| More types of data will be added | OOP (add classes) |
| More operations/processing will be added | FP (add functions) |
| Both will grow equally | Visitor, 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
shape |> area() |> format()// F#
shape |> area |> format-- Haskell (& operator from Data.Function)
shape & area & formatThese read almost identically to shape.area().format(): "perform area on shape, then perform format on the result."
Reference: Haskell Styles
| Notation | Direction | Use Case |
|---|---|---|
format (area shape) | inside→out | Plain application |
format $ area $ shape | right→left | Parenthesis elimination |
shape & area & format | left→right | Data flow |
format . area | right→left | Function 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.
// 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 syntaxAdditionally, scope functions enable pipe-like notation:
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
// 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)
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)
import { flow } from "fp-ts/function"
const formatArea = flow(area, format)
// Compose area → format into a new function
formatArea(shape)Plain Nesting
format(area(shape))
// Data flows from inside to outside| Style | Writing Approach | Best For |
|---|---|---|
pipe(shape, area, format) | Data flows | One-time transformations |
flow(area, format) | Assemble functions | Reusable transformations |
format(area(shape)) | Mathematical | Simple 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:
- Place types and functions in the same file to increase cohesion
- For one-time transformations, use
pipefor data-first writing - For reusable transformations, use
flowto compose functions - For simple cases, plain nesting is sufficient
// 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.
