A small refactor made me think: how many times do we mix a principle with mechanisms? For example, Interface Segregation and Dependency Inversion are many times seen naturally in OOP, but they are principles and they survive a change of paradigm. TypeScript is this interesting case: it supports both OOP and functional styles in the same codebase, and both can preserve the I and D from SOLID. This article separates the principles from the mechanisms that carry them, maps how OOP and functional TypeScript preserve the same design responsibilities. And it proposes yet another comparison of things that look tied together, but are separate concepts in the language structures underneath: where structural typing and duck typing share a philosophy but diverge in practice.
Contents
- The in-memory repository that made the mapping visible
- Preserve the responsibilities, change the mechanisms
- Map responsibilities, not syntax
- Service factories, closures, and composition
- Structural typing: the conformance mechanism
- Testing: where duck typing walks back in
- Untangling the five concepts
- The language field guide
- A good way to use them
- The honest counterpoint
- Checklist
- Recap
Obs.: Check the Glossary at the end if you need clarification on concepts I am using in the article.
The in-memory repository that made the mapping visible
In a production TypeScript API I work on, the persistence design was still evolving while the Use Cases needed to move forward. We deliberately introduced an in-memory implementation of the domain contract:
// infrastructure/repository/in-memory.user.repository.ts
import { randomUUID } from 'node:crypto';
import type { NewUser, User, UserRepository } from '../../domain/user';
const usersByDocument = new Map<string, User>();
async function findByDocument(document: string): Promise<User | null> {
return usersByDocument.get(document) ?? null;
}
async function create(data: NewUser): Promise<User> {
const user: User = { id: randomUUID(), ...data, createdAt: new Date() };
usersByDocument.set(data.document, user);
return user;
}
export const inMemoryUserRepository: UserRepository = { findByDocument, create };
The services that depended on UserRepository received it as a parameter:
export async function register(
input: RegisterInput,
users: UserRepository,
): Promise<User> {
const existing = await users.findByDocument(input.document);
// ...
return users.create(input);
}
At the call site, the temporary seam was explicit:
await register(input, inMemoryUserRepository);
That parameter was deliberate scaffolding. It allowed the Use Cases to progress against the contract while making the temporary in-memory choice visible wherever the service was called.
Later, the Postgres implementation of UserRepository became available. We began removing the extra repository parameter and moving toward an agreed convention: import the repository and use it inside the service. That convention itself would become part of the question.
import { postgresUserRepository } from '../infrastructure/repository/postgres.user.repository';
export async function register(input: RegisterInput): Promise<User> {
const existing = await postgresUserRepository.findByDocument(input.document);
// ...
return postgresUserRepository.create(input);
}
That was the direction of the refactor, not the conclusion of the design. It removed the temporary parameter and restored consistency with the surrounding code, but it also moved the implementation choice directly into the service and removed the visible substitution boundary. In PHP, I knew exactly where each responsibility lived. In TypeScript, the functional style I was working in didn't use those mechanisms.
In the OOP style, each responsibility had an obvious home: a small interface supported Interface Segregation; constructor injection supported Dependency Inversion; a Service Container selected implementations and assembled the graph; tests supplied another implementation of the same interface. The direct-import style looked different enough to raise the real question:
Am I giving up Interface Segregation, Dependency Inversion, centralised composition, or isolated testing? Is functional TypeScript unsuitable for these principles, or am I looking for their OOP mechanisms instead of their functional equivalents? Should this service become a class, or can the functional style preserve the same design qualities from implementation through testing?
The refactor did not reveal a missing principle. It revealed a comparison worth making: for every responsibility that had an obvious home in the OOP style, what technique (and what language structure) gave it an equivalent home in the functional style?
That comparison turned out to be a useful exercise in itself. Not just which pattern to use, but what is a principle, what is a mechanism, and what is a language structure. Where each of those pieces actually plays its role. The rest of this article works through that distinction, one responsibility at a time.
Preserve the responsibilities, change the mechanisms
The comparison becomes clearer when the creation of one service is separated from the composition of the whole application:
| Design responsibility | Common OOP mechanism | Functional TypeScript mechanism |
|---|---|---|
| Keep the consumer contract narrow | A small, consumer-specific interface | A small structural contract |
| Create one service instance | Invoke a class constructor directly or through a dedicated factory | Invoke a service factory such as makeRegistrationService(...) |
| Retain injected dependencies | Private object fields | The returned functions' lexical closures |
| Compose the application graph | Invoke constructors/factories manually at the composition root, or use a Service Container | Invoke service factories manually at the composition root, or use a Service Container |
| Expose the wired application | The entry point receives or resolves a root service/application object | The composition function returns an application facade with already-wired operations |
| Test a consumer in isolation | Inject a fake/mock implementation through the constructor | Pass a structural or duck-typed double to the service factory |
This makes the factory equivalence explicit. makeRegistrationService(users) is doing two familiar jobs at once: its parameters are the functional counterpart of constructor injection, and invoking it is the functional counterpart of constructing the service through new or through a small OOP factory. The returned closure or object of functions is the service instance.
It also keeps composition, composition root, and Service Container distinct:
- Composition is the activity of assembling the graph.
- The composition root is the application location where that activity happens.
- A Service Container is one optional mechanism for performing composition and resolution, often with lifecycle, scope, or discovery features. It can be used in either an OOP or functional application.
- An explicit composition function is manual composition, not a complete replacement for every container capability.
The composed result is another role again. The application.register operation on the returned application facade is a ready-to-use entry point into the wired graph. It is not a Service Container because callers are not resolving arbitrary services by token, and it is not managing service lifecycles. If application code asks a container for dependencies directly, that moves toward the Service Locator pattern rather than dependency injection.
None of these mechanisms creates a design principle by itself. A large interface still violates ISP. A constructor or factory typed to a concrete class still violates DIP. A container or composition function can centralise poor dependencies as efficiently as good ones. The value comes from assigning each responsibility deliberately.
Import/export syntax is not part of the comparison; both styles use ordinary module boundaries. The meaningful question is where the concrete implementation is selected. In the opening refactor, the Use Case selects postgresUserRepository itself and hard-wires the detail. In the mapped design, the composition root selects it and the service factory receives only the UserRepository contract.
The discovery wasn't any of the individual concepts, they were all familiar. It was the mapping between coding styles. How do OOP and functional TypeScript use different techniques and language structures to preserve Interface Segregation and Dependency Inversion, from implementation through isolated testing? Asking that comparative question put contracts, constructors, factories, closures, composition, containers, facades, and test doubles in their respective roles. The concepts stayed the same; the mechanisms changed.
That comparison opened a second layer: what structure in the language makes each technique possible? Class constructors and object fields create and retain dependencies in the OOP route. First-class functions and lexical closures do the equivalent work in the functional route, while ordinary function application composes the graph. TypeScript's structural type system checks contracts without nominal declarations. JavaScript's dynamic dispatch allows duck-typed doubles at runtime, while TypeScript can check the same doubles structurally before tests run. The organizing chain is therefore not only principle → technique, but design responsibility → technique → enabling language structure.
Map responsibilities, not syntax
Dependency Inversion and dependency injection are related, but they are not the same thing:
- Dependency Inversion (the D in SOLID) is a principle about direction: high-level policy must not depend on low-level detail; both depend on abstractions.
- Dependency injection is a technique that serves it: bind a concrete dependency outside the code that consumes it.
The OOP version stores an injected dependency in an object:
class RegistrationService {
constructor(private readonly users: UserRepository) {}
async register(input: RegisterInput) {
const existing = await this.users.findByDocument(input.document);
// ...
}
}
The functional version stores the same dependency in a closure:
function makeRegistrationService(users: UserRepository) {
return {
async register(input: RegisterInput) {
const existing = await users.findByDocument(input.document);
// ...
},
};
}
Both consumers depend on UserRepository, not on a database implementation. Both receive the concrete dependency from somewhere else. The difference is where the language stores the provided value: an object field in one case, a lexical environment in the other.
Composition completes the mapping:
// composition/application.ts
import { postgresUserRepository } from '../infrastructure/repository/postgres.user.repository';
function createRegistrationApplication(users: UserRepository) {
const registration = makeRegistrationService(users);
return { register: registration.register };
}
const application = createRegistrationApplication(postgresUserRepository);
await application.register(input);
The composition root chooses the Postgres implementation, injects it once, and receives an application facade. Calling application.register does not choose, construct, or locate a repository. In a container-based application, a Service Container may perform this composition at the root. Here the composition function performs it manually. Neither the composition function nor the returned facade is itself a Service Container.
This is also where Interface Segregation fits. UserRepository should describe only what the registration Use Case needs. Structural typing does not guarantee ISP (nothing prevents us from declaring a fat contract), but it removes much of the implementation cost of keeping contracts small. Any value with the required shape can satisfy the consumer's contract without joining an inheritance hierarchy or declaring implements. (More details about implements down in the article.)
Service factories, closures, and composition
Three elements do different work here: the service factory creates a service, its parameters are the injection boundary, and the returned functions' closures retain the injected dependencies.
At the service level, calling makeRegistrationService(users) supplies the contract-typed dependency and creates the service. Every returned function then closes over users. This is the direct functional counterpart of a constructor receiving a value, assigning it to a private field, and producing an object instance, without exposing the dependency to every service call.
At the application level, a composition function can invoke several service factories and build the graph. The next example generalises the earlier registration-only case into a composition function for the full application graph:
interface ApplicationDependencies {
users: UserRepository;
mailer: Mailer;
clock: Clock;
}
function createApplication(dependencies: ApplicationDependencies) {
const registration = makeRegistrationService(dependencies.users);
const profile = makeProfileService(dependencies.users);
const reminders = makeReminderService(dependencies.mailer, dependencies.clock);
return {
register: registration.register,
getProfile: profile.get,
sendReminders: reminders.send,
};
}
Call createApplication(...) once at the composition root, and each returned service closes over only the slice of the graph it needs; collectively, those services expose the complete, already-wired application. Production passes database, mail, and system-clock implementations. Tests pass in-memory repositories, recording mailers, and fixed clocks. The consumers remain unchanged.
Closure-based composition can make a Service Container unnecessary for a simple, static graph, but it is not a full replacement for the abstraction. The composition function builds the graph and each returned closure retains its relevant part. Together they cover the container's core composition responsibility: construct the graph centrally, bind implementations to contracts, and expose ready-to-use services. A container may additionally provide autowiring, named bindings, scopes, lifecycle management, decorators, interception, or plugin discovery, and it remains available in functional code when those capabilities earn their cost. In TypeScript, autowiring still needs runtime anchors such as classes or explicit string/symbol tokens; erased structural interfaces cannot be resolved directly. Manual composition is sufficient when those capabilities are not needed.
The two routes can now be summarised without treating module syntax as part of the comparison:
- OOP: segregated interface → constructor/factory → dependencies retained in object fields → manual or container-assisted composition → root application object
- Functional TypeScript: structural contract → service factory → dependencies retained in closures → manual or container-assisted composition → application facade
The architecture is preserved because each responsibility still has an explicit home. What changes is the default mechanism: lexical scope replaces instance fields, service factories replace construction through new, structural conformance replaces nominal declarations, and explicit composition can replace container resolution when the graph does not need richer container capabilities.
Anton van Straaten's fictional Qc Na koan captures the first equivalence: "objects are a poor man's closures; closures are a poor man's objects." Both bundle behaviour with retained state. Here that state includes the in-memory repository's Map and, in each service closure, precisely the dependencies that service needs.
Structural typing: the conformance mechanism
Service factories and closures answer how one service is created and retains its injected dependencies; the composition root answers where the full graph is assembled. Structural typing answers a simpler question: how does the compiler know a value satisfies the contract its consumer expects? It's what makes the functional mapping safe, not just short.
In a structurally typed system, a value satisfies a type by having the right shape: the required members, with compatible types. Names and declarations are irrelevant. UserRepository defines one such shape:
interface UserRepository {
findByDocument(document: string): Promise<User | null>;
create(data: NewUser): Promise<User>;
}
Any value with those two methods, and compatible signatures, has that shape. So when the in-memory repository assigns its object literal to that type. Consider:
export const inMemoryUserRepository: UserRepository = { findByDocument, create };
That single line is the conformance check. It's compile-time, exhaustive, and free: if the object is missing a method, has one with an incompatible signature, or the contract later grows, this line stops compiling. No implements, no registration, no adapter class. And it generalises: any object literal, class instance, or factory result that has the shape is a UserRepository. The contract is decoupled from every implementation's ancestry, which is why an in-memory object literal and a Postgres-backed implementation can be substituted at the composition root without changing a consumer.
You can watch the check evaporate the moment it's done its job. The in-memory repository didn't need implements. A Postgres-backed class can still use it for clarity, but it's optional, and it disappears at runtime. The TypeScript source:
class PostgresUserRepository implements UserRepository {
async findByDocument(document: string) { /* ... */ }
async create(data: NewUser) { /* ... */ }
}
compiles to plain JavaScript with no trace of the contract:
class PostgresUserRepository {
async findByDocument(document) { /* ... */ }
async create(data) { /* ... */ }
}
The interface and implements annotation disappear because they are type-system constructs. The class remains because a class is also a runtime JavaScript construct. Conformance was proven once, before the program ran, and then discarded. It's also why a TypeScript DI container can't autowire by interface the way C# or PHP can: at runtime there's no UserRepository to resolve against, only classes and string/symbol tokens. The same erasure that keeps contracts lightweight is the reason reflection-based containers cannot autowire by interface and must use runtime classes or explicit tokens.
This is also where the I of SOLID becomes cheaper to practise. A small contract requires little implementation ceremony (no base class, no implements, no registration), so keeping interfaces role-specific adds less friction. In a nominal OOP language, each new segregated interface adds a declaration that its implementations must adopt; structural typing removes that declaration tax. It does not guarantee Interface Segregation, but it makes the principle easier to apply consistently.
The contrast that makes this concrete is a nominal language, PHP, the reference OOP case here:
final class PostgresUserRepository implements UserRepository { /* ... */ }
function register(UserRepository $users): void { /* ... */ }
Here conformance is declared. An object with exactly the right methods but no implements UserRepository is not a UserRepository: pass it to register() and PHP throws a TypeError. Same shape, wrong ancestry, rejected. This is one reason adapters and containers become more prominent in nominal ecosystems: conformance is explicitly declared, so wiring becomes a first-class activity with its own tooling.
One distinction worth stating now, because it drives the rest of the post: TypeScript is structural for interfaces, object types, and (mostly) classes, and it checks that conformance statically, before the program runs. "Checked statically" is easy to wave past, but it's the entire difference between structural typing and the thing it gets mistaken for. And the fastest place to feel that difference isn't in production at all. It's the moment you sit down to write a test.
And I am a Test-Driven Developer. Therefore...
Testing: where duck typing walks back in
The style survives contact with production easily enough. The seam is typed, the closure captures a contract-typed value. Where it gets interesting is the first time you write a test.
To test a service in isolation, you need a stand-in for its dependency, a repository that never touches a database. In JS/TS we've done this for years with an ad-hoc object: just the methods the test exercises, nothing more. And here the old instinct kicks in, reach for a loose double:
// duck-typed double: no contract in sight
const users = { findByDocument: async () => null } as any;
This works. The test runs, the service calls findByDocument, the call resolves. That's duck typing: the object is accepted because, at the moment of the call, it happens to have the method. Nothing checked its shape ahead of time; the as any explicitly threw the check away.
And it raises the question this whole post circles: is that the same thing as structural typing? It feels identical, both say "shape is enough, ancestry is irrelevant." But the difference is exactly the when. Type the same double against the contract instead:
// structurally-typed double: checked against the contract, now
const users: UserRepository = {
findByDocument: async () => null,
create: async (data) => ({ id: 'x', ...data, createdAt: new Date() }),
};
Now the compiler enforces the whole shape before the test runs. Forget create, or let the real UserRepository grow a method, and this double stops compiling; the test tells you it drifted. The as any version compiles happily and only fails later, at runtime, if that path is even exercised.
Same philosophy, opposite failure mode. The loose double (as any) is widely used and genuinely convenient, and it's quietly the reason so many of us assume duck typing and structural typing are one thing. They aren't: typing the double : UserRepository is the small discipline that keeps a test double in the structural camp, and it's worth doing. But the recommendation isn't the point here. The point is that the moment you write a mock is the moment the two concepts visibly separate, which is exactly what the next section pulls apart.
Untangling the five concepts
Five ideas have been doing distinct work so far. Placing them side by side makes the boundaries explicit:
| Concept | What it is | What it is not |
|---|---|---|
| Dependency inversion / injection | DIP: a principle about direction; depend on abstractions (the D in SOLID). DI: a technique serving it; bind concretions elsewhere | A framework, a container, or anything inherently class-shaped |
| OOP constructs | Classes, constructors, implements; one mechanism for DI and encapsulation |
The definition of DI, or a requirement for Interface Segregation or Dependency Inversion |
| Closures | Functions capturing their environment; another mechanism for retained private state, injected dependencies, and explicit graph composition | A Service Container abstraction by definition, or a hack until "real" OOP arrives |
| Structural typing | Static conformance decided by shape; checked before the program runs | A dynamic-language feature; permission to skip contracts |
| Duck typing | Runtime conformance: "if it quacks"; discovered at the call site, or not at all | A synonym for structural typing |
Read down the "what it is not" column and the responsibilities separate cleanly. Dependency injection is not a framework: an explicit composition function can bind the graph as truly as a container. OOP constructs are not the definition of DI: they're one mechanism among several, the one that happens to dominate nominal ecosystems. Closures are not a hack or a container by definition: they are a language mechanism that can retain one dependency or the complete composed graph.
The two that cause the most trouble are the last ones: structural typing vs duck typing. They share a philosophy, behaviour over ancestry, shape over name, and in TypeScript they even look identical to write, which is why they get treated as one thing. They are not. The difference is when the shape is checked. Structural typing checks it at compile time, by a type checker, before the program runs; the failure is a red squiggle in your editor. Duck typing checks it at runtime, at the moment of the call, by the call itself; the failure is a thrown error, or worse, silence, if the missing method simply never gets reached on that path.
Which answers the question the testing section left open: if structural typing isn't what makes duck typing possible, what does? The enabler is dynamic dispatch, the language resolving x.quack() against whatever x actually is at call time, with no prior type commitment required. That's a property of the runtime, not of the type system, and it's independent of whether conformance is judged by shape or by declaration. The clean proof is Python: it has had duck typing forever, with no structural type system at all; typing.Protocol (PEP 544) later added static structural checking as an opt-in for external type checkers, and the duck-typed runtime underneath didn't change one bit. So duck typing and structural typing aren't two names for one idea, they're independent answers to two different questions, how? and when?, and a language can choose each answer separately.
The language field guide
Put the two questions on two axes, how conformance is decided (by shape, or by declaration) and when it's checked (statically at compile time, or at runtime), and every language lands in a cell:
| Checked statically (compile time) | Checked at runtime | |
|---|---|---|
| By shape | TypeScript, Go (structural typing) | Python, JS, Ruby (duck typing) |
| By declaration | C#, Java (nominal + static) | PHP with type hints (nominal + runtime) |
The cells people forget are the interesting ones.
- TypeScript: shape, static, and loose. An ad-hoc object literal conforms on the spot, no named type required. The cheapest contracts of any mainstream language. TypeScript provides static structural checking over a dynamically typed JavaScript runtime, so once the types are erased, what's left is duck typing.
- Go: shape, static, and not loose. Interfaces are satisfied implicitly (structural, a type never says
implements), but conformance still needs methods declared on a named type; there are no ad-hoc conforming literals and no runtime "try the call and see." Interface satisfaction operates through named interface types rather than TypeScript-style ad-hoc object compatibility. This is the answer to "is there structural typing without any duck typing?". Go is it, and it isn't a compromise. The idiomatic Go move of defining tiny consumer-side interfaces is Interface Segregation and structural typing working together, straight out of the textbook. - Python / JS / Ruby: shape, runtime. Classic duck typing, enabled by dynamic dispatch. Python then bolts on optional static structural typing via
typing.Protocol(PEP 544) for anyone running a type checker, without touching the duck-typed runtime, the cleanest demonstration that the two are separable. - PHP: declaration, runtime. Nominal, but enforced at call time. With type declarations, objects of classes that declare
implementspass; a mismatch is aTypeErrorwhen the call happens, not a compile error (PHPStan/Psalm drag the check earlier, as a lint). Drop the declarations and PHP becomes dynamically typed: no type enforcement on parameters, method calls resolved at runtime, but objects are still class instances, not ad-hoc shapes. That double identity, nominal-when-typed, dynamic-when-not, plus autowiring by type declaration is the same story that made the containers from section 3 inevitable. - C# / Java: declaration, static. Nominal, checked at compile time. The full ceremony (declare the interface,
implementsit, register it, inject it) and perfectly capable of clean dependency-inverted designs; the container exists to amortise the wiring cost the nominal type system creates in the first place.
The grid shows these are independent axes, and real languages occupy all four cells. Go is structural and statically checked, but without TypeScript's ad-hoc object compatibility; TypeScript is structural-and-loose yet fully static; Python is duck-typed with an optional structural upgrade; PHP is nominal without being static, the quadrant people forget exists. "Structural" and "duck" are not synonyms, and "nominal" and "static" are not the same axis. Once the grid is visible, the testing question becomes precise: a TypeScript double can rely on JavaScript's runtime duck typing, or it can be checked structurally before the test runs. The object may look the same; the guarantee is different.
A good way to use them
None of this argues against classes, and none of it argues against containers. It argues for choosing the mechanism on purpose. A few defaults fall out of everything above:
- Keep contracts in the domain, named in the domain's language.
UserRepositorybelongs indomain/user.ts, not in the infrastructure that implements it; the consumer owns the interface, the implementation conforms to it. This is the Go consumer-defined-interface instinct and Interface Segregation in one move. - For simple functional services, a factory + closure is a natural default. The dependency is captured once and stays private. Keep parameter passing for genuinely local composition or deliberate scaffolding. Put the complete graph in an explicit composition root (
createApplicationor equivalent), invoke it once with production implementations, and retain the returned application facade as the wired root object. That is where implementation choices remain centralised and tests gain a single, typed substitution boundary. - Treat class-vs-closure as a mechanism choice, not a moral one. Pick by team convention and by whether you genuinely need identity or lifecycle semantics, not because one of them is "real" dependency injection. They both are.
It's worth naming the design principle underneath, because it's the reason the cheap option is also the good one. In Vlad Khononov's Balanced Coupling model, a shared contract is the weakest, most distance-tolerant form of coupling between two components, exactly what you want across a module boundary, where the two sides may change and ship independently. Structural typing makes that weakest level of coupling nearly free to express, so in TypeScript the best-balanced design and the least-ceremony design are the same design. The one discipline the model insists on: keep the wiring explicit and greppable, a named seam you can find, because Balanced Coupling's warning is against implicit coupling, and implicit coupling is also structural typing's failure mode. Which is the honest counterpoint.
The honest counterpoint
The price of making conformance free is that conformance becomes implicit. Anything with the right shape satisfies a contract, including things that match only by accident. A single-method contract like { execute(): void } is satisfied by half the objects in a codebase; the type checker will happily accept a wrong one that merely fits. The narrower and more generic the contract, the more coincidental matches it invites, so the defence is to keep contracts role-named and non-trivial (UserRepository, not Doer), so that matching the shape actually means matching the intent.
Implicitness costs tooling, too. Without implements, "find all implementations of this interface" is a weaker query than in a nominal language, and renaming a contract member doesn't announce, at the declaration site, everyone who just broke. You lean harder on the compiler and on find-references, and a little less on the class hierarchy telling you who's involved.
The mitigation turns out to be the same thing as the recommendation from the previous section: an explicit, greppable composition root. A deliberate conformance point, const impl: Contract = ..., typed against the contract, living in that known location, turns implicit shape-matching back into a visible, findable decision. It's also, not coincidentally, the same discipline that keeps a test double structural rather than duck-typed. The functional style doesn't remove the need to be deliberate about coupling; it relocates the deliberateness from the type declaration to the wiring, and rewards you for keeping that wiring in the open.
Checklist
A quick test for "am I doing DI properly in this style?", whether or not you reached for a class:
- Does the consumer depend on a contract, not a concrete implementation? (Take the contract as a factory parameter and capture it.)
- Is the binding external to the consumer, captured by a service factory and selected at the composition root, rather than constructed inline?
- Is every implementation, including test doubles, typed against the contract (
: UserRepository), so drift fails at compile time instead of at runtime? - Are contracts small and role-named, so structural matching signals intent rather than coincidence?
- Is the complete dependency graph explicit and greppable in one composition root, rather than having consumers select concrete implementations throughout the application?
- Did you pick class-vs-closure for a real reason (convention, lifecycle, identity), not out of habit or a belief that one is "real" DI?
Recap
The article started with a refactor that removed a temporary parameter and raised a question: was I giving up Interface Segregation and Dependency Inversion by moving from OOP to functional TypeScript, or was I looking for OOP mechanisms where functional equivalents existed?
The answer turned out to be a comparison worth making. For every responsibility that had an obvious home in OOP, there was a functional technique that gave it an equivalent home:
- Interface Segregation lives in small, role-named contracts. Structural typing removes the declaration tax that keeps contracts fat in nominal ecosystems.
- Dependency Inversion lives in depending on abstractions, not on concretions. A factory parameter captures a contract-typed dependency as cleanly as a constructor.
- Dependency Injection lives in binding concretions outside the consumer. A service factory and closure retain injected state as truly as an object field.
- Composition lives in a single root where implementations are selected and the graph is assembled. A composition function does this as truly as a Service Container.
- Conformance lives in shape, not ancestry. Structural typing checks it statically, before the program runs. Duck typing discovers it at runtime, at the call site. Same philosophy, different when.
- The language field guide put that distinction on two independent axes:
- How conformance is decided: by shape or by declaration.
- When it's checked: statically at compile time, or at runtime.
- Real languages occupy all four cells:
- Go is structural and statically checked, but without ad-hoc object compatibility.
- TypeScript is structural and loose, yet fully static.
- Python is duck-typed with an optional structural upgrade.
- PHP is nominal without being static.
- "Structural" and "duck" are not synonyms, and "nominal" and "static" are not the same axis.
- The honest counterpoint: making conformance free makes it implicit. Anything with the right shape satisfies a contract, including things that match only by accident. The defence is role-named, non-trivial contracts and an explicit, greppable composition root. A deliberate conformance point, typed against the contract, turns implicit shape-matching back into a visible, findable decision.
- The principles stayed the same. The mechanisms changed:
- Classes, constructors, and
implementsare one set of mechanisms. - Closures, factories, and structural contracts are another.
- Classes, constructors, and
- Neither set is the definition of the principles they carry. Picking between them is a mechanism choice, not a moral one.
- The one discipline that travels across both styles: keep the wiring explicit and greppable. A named seam you can find is the difference between deliberate coupling and accidental coupling, whether the seam is a constructor parameter or a factory argument.
Glossary
- OOP — Object-Oriented Programming: a programming style that organises state and behaviour around objects. For comparison, this article uses a common class-based arrangement: interfaces, constructors, implementations, and a Service Container.
- SOLID: a family of five design principles associated with maintainable and adaptable software. This article focuses only on its I and D.
- ISP — Interface Segregation Principle: consumers should not be forced to depend on operations they do not use; prefer small, role-specific contracts.
- DIP — Dependency Inversion Principle: high-level policy should not depend directly on low-level details; both should depend on abstractions, and details should depend on those abstractions.
- DI — Dependency Injection: a technique for supplying dependencies from outside the consumer. DI can support DIP, but the two are not synonyms.
- Closure: a function together with the lexical environment it retains, allowing injected values to remain available after the factory that received them has returned.
- Service Container: a central assembler or registry that selects implementations and wires services, often with additional lifecycle or resolution features.
- Composition root: the single application location where concrete implementations are selected and the dependency graph is assembled.
- Seam: a place in code where behaviour can be varied without editing the code that uses it. Coined by Michael Feathers in Working Effectively with Legacy Code. In this article, the repository parameter is a seam: you swap implementations at the call site without touching the service.
Further reading and references
Glossary concepts
- OOP - MDN glossary — concise introduction to object-oriented programming and JavaScript's prototype-based model.
- SOLID - Wikipedia — the five-principle family; this article focuses on ISP and DIP.
- The Interface Segregation Principle - Robert C. Martin (PDF) — the original detailed treatment of cohesive, client-specific interfaces.
- The Dependency Inversion Principle - Robert C. Martin (PDF) — the original statement that policy and detail should depend on abstractions.
- Inversion of Control Containers and the Dependency Injection Pattern — Martin Fowler — dependency injection, container wiring, and the separation of configuration from use.
- Composition Root - Mark Seemann — the application location where modules and concrete dependencies are assembled.
- Working Effectively with Legacy Code - Michael Feathers — the origin of the "seam" concept: a place in code where behaviour can be varied without editing the code that uses it.
Language and design mechanics
- TypeScript Handbook - Type Compatibility — TypeScript's structural type system.
- The Go Programming Language Specification - Interface types — implicit, statically checked interface satisfaction.
- PEP 544 - Protocols: Structural Subtyping — Python's opt-in static structural typing.
- PHP Manual - Type System — PHP's nominal type system and runtime verification.
- Balanced Coupling - Vlad Khononov — integration strength, distance, volatility, and contract coupling.
- The Qc Na closures/objects koan - Anton van Straaten — the equivalence between retained state in closures and objects.
- Simple Made Easy - Rich Hickey — separating simplicity from familiarity and convenience.