Accompanying repo here: https://github.com/FranDepascuali/extensions-ts
Exploring different programming languages and ecosystems is an excellent way to expand our technical understanding of software systems that surround us. They equip us with all sorts of new tools to tackle problems, helping us avoid the pitfall described as the Law of the Instrument:
“Give a small boy a hammer, and he will find that everything he encounters needs pounding.” [Abraham Kaplan, 1964]
In this post, I would like to introduce a concept known as extensions, which is originally derived and inspired by the Swift programming language. Extensions in Swift allow developers to add new functionality to existing classes, structures, enumerations, or protocols.
The purpose of this post goes beyond merely introducing extensions; my intent is to demonstrate how they can fundamentally transform our approach to structuring and developing programs.
This essay will describe how we structure code using extensions, delve into aspects of Object-Oriented Programming (OOP) and Functional Programming (FP), and explore philosophical aspects of software programming. While I’ll be discussing extensions in the context of TypeScript, the techniques presented here could be applicable to almost any language that supports functions, as they are based on convention rather than on a language-specific feature.
In the Swift programming language, a feature known as extensions allows developers to augment types with additional functionality. For instance, developers can introduce new methods to the Array type, making them accessible to every array instance. This approach significantly enhances the organization and readability of the code.
According to the official Swift documentation:
Extensions add new functionality to an existing class, structure, enumeration, or protocol type. This includes the ability to extend types for which you don’t have access to the original source code
Consider a simple example where we extend UIColor
to include new functionalities that will be available for every instance of UIColor
:
extension UIColor {
convenience init(rgb: Int, a: CGFloat = 1.0) {
self.init(
red: (rgb >> 16) & 0xFF,
green: (rgb >> 8) & 0xFF,
blue: rgb & 0xFF,
a: a
)
}
static func random() -> UIColor {
return UIColor(red: .random(),
green: .random(),
blue: .random(),
alpha: 1.0)
}
func withOpacity(_ opacity: CGFloat) -> UIColor {
return withAlphaComponent(opacity)
}
}
With these extensions in place, we can invoke them as if they were inherent to UIColor
:
let color = UIColor(rgb: 0xFF5733)
let otherColor = UIColor.random()
let opaqueColor = color.withOpacity(0.5)
Using extensions can serve as a substitute or alternative for traditional utility classes. Instead of structuring code around a somewhat nebulous concept suffixed with “Utils,” we group code into extensions that enhance an entity’s capabilities. Consequently, our project might have a directory structure resembling src/extensions/
, which could include:
UIColor+Extensions.swift
String+Extensions.swift
Array+Extensions.swift
It’s very common for every Swift developer to regularly use extensions, so much so that their absence is noticeable when working in other languages. This led me to devise a method to implement extensions in TypeScript and, in theory, any language.
Suppose we want to enhance an existing type, such as Array
, by declaring an extension with functions we want every array to have:
Array.extension.ts
export namespace Arrays {
export function minBy<T>(array: T[], iteratee: (value: T) => number): T | undefined {
...
}
export function partition<T, M>(
xs: T[],
pred: (arg0: T) => boolean,
transformer: (arg0: T) => M,
): [M[], M[]] {
...
}
export function zip<T, U>(array: T[], array2: U[]): Array<[T, U, number]> {
return array1.map((element, index) => [element, array2[index], index]);
}
export function compactMap<T, U>(array: T[], mapper: (element: T) => U | null): U[] {
...
}
export function withoutDuplicates<T>(
array: T[],
keyFn: (item: T) => any = (item: T) => item as any,
): T[] {
...
}
// Other functions
}
Similarly, we can extend Object
:
Object.extension.ts
export namespace Objects {
export function removeProperties<T extends Record<string, any>, K extends keyof T>(
obj: T,
properties: K[],
): Omit<T, K> {
const newObj: T = { ...obj };
for (const prop of properties) {
delete newObj[prop];
}
return newObj;
}
export function isEmpty(obj: any): boolean {
if (!obj) {
return true;
}
return Object.keys(obj).length === 0;
}
}
This pattern can be applied to any other type.
Key Takeaways:
Arrays.
in your IDE would suggest all available functions from the Array
extension.A more detailed explanation of these design choices will follow in subsequent sections.
When discussing programming concepts, it’s crucial to distinguish between the idea we want to express (denotational semantics) and the actual implementation (operational semantics). Ideally, the translation from syntax to semantics is immediate, but this isn’t always the case. For extensions, this distinction is particularly significant.
Swift, for instance, uses the “extension” keyword, simplifying the process significantly. However, not all languages inherently support extensions. In situations where we don’t have a direct translation of the idea, we might either seek language support or resort to conventions. The latter approach offers added flexibility, as it’s not restricted to any particular language.
Based on these considerations, I’ve adopted the following practices for implementing extensions:
<entity>.extension.ts
, although this can vary at the implementer’s discretion.I want to make a disclaimer here: I’ve presented extensions as a concept, as a way of thinking about code and conceiving parts of our systems. Extensions can be applied in various ways.
The preferred way to implement extensions in TypeScript is by using namespaces
Example using TypeScript namespace:
export namespace Prismas {
function getAllUsers(prisma: PrismaClient): Promise<User[]> {
return prisma.user.findMany();
}
function addUser(prisma: PrismaClient): Promise<User> {
return prisma.user.create({ data: userData });
}
}
Note that TypeScript namespaces have been deprecated, though they haven’t been removed from the language and likely won’t be. They are deprecated because they are a feature from typescript and not from javascript itself (source)
Export a class with functions:
export abstract class Prismas {
static function getAllUsers(prisma: PrismaClient): Promise<User[]> {
return prisma.user.findMany();
}
static function addUser(prisma: PrismaClient): Promise<User> {
return prisma.user.create({ data: userData });
}
}
Honestly, I’m not fond of using a class for this purpose. However, JavaScript and TypeScript encourage structuring code using modules and classes.
Why static functions? We don’t want instances or anything related to OOP. These functions are meant to be standalone, so we declare them as static to avoid the need for instantiation.
Why abstract? We don’t intend for these classes to be inherited or instantiated. Declaring them as abstract makes it clear that they are not to be used as typical classes. This way, we prevent any misuse or misunderstanding of their purpose.
Smart developers might suggest that you can achieve similar functionality with prototypes. However, I would advise against using prototypes, although the choice ultimately lies with you.
Using prototypes comes with significant risks. You might inadvertently break someone else’s code, leading to unpredictable and hard-to-trace issues. There’s a well-articulated rationale about the dangers of using prototypes in the proposal for Array grouping on GitHub:
We’ve found a web compatibility issue with the name Array.prototype.groupBy. The Sugar library until v1.4.0 conditionally monkey-patches Array.prototype with an incompatible method. By providing a native groupBy, these versions of Sugar would fail to install their implementation, and any sites that depend on their behavior would break. We’ve found some 660 origins that use these versions of the Sugar library.
This example illustrates the broader problem: modifying prototypes can conflict with other libraries or future language updates. It’s an invasive technique that alters the behavior of all objects of that type, potentially leading to unexpected and far-reaching consequences. Therefore, while prototypes can be powerful, they should be used with extreme caution and a thorough understanding of the potential implications.
Another possibility I explored is exporting an object with the functions:
export const Prismas = {
getAllUsers(prisma: PrismaClient): Promise<User[]> {
return prisma.user.findMany();
},
addUser(prisma: PrismaClient): Promise<User> {
return prisma.user.create({ data: userData });
}
}
While this approach is straightforward and easy to understand, it has a significant drawback concerning tree shaking. Tree shaking is an optimization technique used to remove unused code, thereby reducing the size of the final bundle. However, when you export an object with functions like this, the entire object is instantiated with all the functions, regardless of whether they are used. This can prevent tree shaking from effectively eliminating unused code, leading to larger-than-necessary bundles. (https://webpack.js.org/guides/tree-shaking/)
We’re all familiar with how utility (or helper) functions can grow exponentially. When we designate something as a utility, we often don’t set precise boundaries for its scope. Consequently, we end up with massive, unwieldy files filled with an indiscriminate mix of functions that reference various entities in our code.
Furthermore, extensions facilitate code sharing across different libraries, frameworks, and any TypeScript-based code. For instance, if I wanted to share an array extension with you, all you would need is that single file. Conversely, utilities often intertwine array operations with domain-specific models, which complicates code reuse and sharing.
Moreover, extensions facilitate code sharing across different libraries, frameworks, and any TypeScript code. For instance, I could share an array extension with you, and all you’d need is that single file. In contrast, utilities often entangle array operations with domain-specific models, complicating code reuse and sharing.
By imposing a more disciplined structure and providing an elegant method for sharing code, extensions don’t just replace traditional utils; they significantly elevate the maintainability and clarity of our codebases. They encourage a cleaner, more modular approach to code organization, making our projects easier to understand, maintain, and enhance.
A frequently overlooked aspect of objects is that methods can essentially be viewed as functions that receive an instance of the class on which they are declared. This perspective is not just theoretical; some languages implement methods precisely in this manner.
Consider an OOP example of a counter:
class Counter {
count: number;
step: number;
constructor(initialCount = 0, step = 1) {
this.count = initialCount;
this.step = step;
}
increment() {
this._updateCount(this.step);
}
decrement() {
this._updateCount(-this.step);
}
setStep(newStep: number) {
this.step = newStep;
}
getCount() {
return this.count;
}
private _updateCount(change: number) {
this.count += change;
}
}
Now, suppose getCount() returns an incorrect value. Debugging this issue would typically involve examining getCount(), reviewing every method that modifies the state, and hypothesizing why the value is incorrect. Let’s say the problem arises from non-atomic calls to increment() in a multithreaded environment. This kind of bug is challenging to analyze and is further complicated by expressing this program as an object.
The first step to addressing these types of issues is to acknowledge the pain they cause and approach them with an open mind. The worst outcome is learning something new and improving your ability to develop systems. By recognizing these challenges, we open ourselves up to other solutions we may never thought existed.
We could change the methods into functions that receive their dependencies:
class Counter {
count: number;
step: number;
constructor(initialCount = 0, step = 1) {
this.count = initialCount;
this.step = step;
}
increment() {
this.count = _updateCount(this.count, this.step);
}
decrement() {
this.count = _updateCount(this.count, -this.step);
}
setStep(newStep: number) {
this.step = newStep;
}
getCount() {
return this.count;
}
}
function _updateCount(count: number, step: number) {
return count + step;
}
By gradually removing methods from your classes, you reserve the use of methods for special cases. You’ll have a set of “free” functions and a class with less code. However, the most crucial aspect is the clarity at the point of use and the limits now imposed on methods.
Consider these two signatures:
function _updateCount(count: number, step: number): number // Function
function _updateCount(this: Counter) // OOP method
The first definition clearly uses two parameters and returns a number. In contrast, the method receives a Counter, with ambiguity about what it might do with the Counter instance. How do you prevent _updateCount from performing all sorts of esoteric operations on the Counter instance itself? (not now, maybe not you, but in the future, when others continue building upon it)
Functions, with proper discipline, are constrained to perform only their intended actions, whereas methods have the potential to do anything outside of what they should do.
I want you to ask yourself this question: is this how we choose to design our systems?
Coming back to our Counter example:
class Counter {
count: number;
step: number;
constructor(initialCount = 0, step = 1) {
this.count = initialCount;
this.step = step;
}
increment() {
this.count = _updateCount(this.count, this.step);
}
decrement() {
this.count = _updateCount(this.count, -this.step);
}
setStep(newStep: number) {
this.step = newStep;
}
getCount() {
return this.count;
}
}
function _updateCount(count: number, step: number) {
return count + step;
}
Now, let’s reconsider what a Counter truly is. Why did we introduce a class (and therefore instances) for a counter? How did we end up structuring our systems in this manner? What if we approached it differently?
Redefine the Counter, this time with extensions in mind. We’ll describe the Counter as an interface, not a class:
interface Counter {
count: number;
step: number;
}
// Usually in Counter.extension.ts
export namespace Counters {
function increment(counter: Counter): Counter {
return {
count: counter.count + counter.step,
step: counter.step,
};
}
}
function decrement(counter: Counter): Counter {
return {
count: counter.count - counter.step,
step: counter.step,
};
}
}
At first glance, it might seem similar to the class-based approach, but it’s fundamentally different. For instance, you can serialize and deserialize this version of Counter using Node’s JSON serialization tools. But how do you serialize and deserialize an instance of a class? Typically, it requires hacks or external libraries like https://github.com/typestack/class-transformer
This illustrates the complexity we inadvertently introduce by defaulting to classes everywhere, even when we’re merely looking to describe data!
I want to provide a mental model for applying extensions to help you try it out. It’s not as complex as it may seem at first. Once you start, you’ll likely find the process quite intuitive.
Remember, the goal isn’t to eliminate classes entirely or to use extensions in every scenario. Instead, it’s about making deliberate, thoughtful choices about when and how to use these tools. By starting with a simpler, more function-oriented approach and using extensions thoughtfully, you’ll likely find your code becomes easier to understand, maintain, and evolve.
Upon the whole, I am inclined to think that the far greater part, if not all, of those difficulties which have hitherto amused philosophers, and blocked up the way to knowledge, are entirely owing to ourselves—that we have first raised a dust and then complain we cannot see.
Excerpt From: A Treatise Concerning the Principles of Human Knowledge, George Berkeley (1685 - 1753)
If you’re a developer (which I assume you are), you probably already have some experience with OOP in any language. You’re used to instantiating classes, calling methods, understanding inheritance, and grasping how programming languages decide which method to dispatch based on subclasses. All these terms are likely in your vocabulary: class
, object
, inheritance
, encapsulation
, dispatch
, polymorphism
, abstraction
, method
, override
, instance
, property
, destructor
, interface
, overloading
, superclass
, subclass
, abstract class
, composition
, DTO
, DAO
.
Of course, you also know all of these design patterns by heart: creational patterns
(singleton
, factories
, prototype
), structural patterns
(adapter
, composite
, proxy
, flyweight
, facade
, bridge
, decorator
), and behavioral patterns
(strategy
, observer
, command
, template method
, iterator
, state
, visitor
, mediator
, memento
, chain of responsibility
, interpreter
). I could continue with more advanced OOP constructs: abstract factories
, generics mixed with OOP code
, etc.
Do you realize the madness we’ve created as an industry? It’s quite evident how complex we’ve made our systems. OOP has ingrained so many concepts into our thought process that we often don’t see the complexity it introduces. It has strayed far from the basic concept of a program, under the premise that ‘everything is an object’, leading us into a web of abstractions and the all-too-familiar bugs in our codebases. Of course, that’s my appreciation, and I don’t have more proof than my own experience in different codebases over the years.
So let’s go back to the starting point and ask ourselves: what is a program?
Let’s reconsider the essence of a program. Traditionally, from an imperative standpoint, you might define it as “a set of instructions to achieve an objective.” However, this definition is not entirely accurate, especially in the context of functional programming or AI, where we describe how a program should work rather than dictating specific instructions.
So we need a different definition of a program:
A program is a description of a solution to a problem executable by an automatizable mechanism
In OOP, that description consists of objects that interact with each other. I want to challenge the conception that the word “object” is something easy to understand (it’s not) and the inherent problems objects lead us to by considering they exist, standalone, by themselves, with their own identity. As engineers and thinkers, we should be questioning our principles, we should not just accept what is given.
I’m not advocating for a complete paradigm shift or insisting that you must learn an entirely new way of programming. Instead, I’m proposing we start with something universally present in almost every programming language: functions. Embracing functions doesn’t necessarily mean committing to a functional-oriented programming style. It’s important to clarify this, as functional programming encompasses a broad and complex range of concepts, too extensive to cover in this essay.
Extensions offer more than just a way to group functions; they present a viable alternative to OOP-based hierarchies. Typically, in TypeScript, you might default to classes and objects, like so:
class Person {
public name: string;
public age: number;
public email: string;
constructor(name: string, age: number, email: string) {
this.name = name;
this.age = age;
this.email = email;
}
greet(): void {
console.log(`Hello, my name is ${this.name}!`);
}
isAdult(): boolean {
return this.age >= 18;
}
}
Now, consider shifting your mindset to leverage types and extensions:
interface Person {
name: string;
age: number;
email: string;
}
export namespace Persons {
greet(person: Person): void {
console.log(`Hello, my name is ${person.name}!`);
}
isAdult(person: Person): boolean {
return person.age >= 18;
}
}
Imagine applying this pattern throughout your code. You could significantly reduce your reliance on classes and objects and embrace a different, more stateless approach. This isn’t to say that extensions are the end-all solution for every scenario, but they open up a path that’s worth exploring.
We use “extension” because it signifies adding new capabilities to an existing entity. It is not meant to be confused with inheritance.
It’s a convention. As we don’t want to collide with the original type we are extending, the pluralize version is good enough, but it’s up to you to decide a different convention.
No, just TypeScript. This concept is adaptable to many languages, similar to Rust traits and Swift extensions.
Of course! What they have taught you about OOP is arguably right, instead of coming up with fancy dependency injection frameworks and stuff, pass parameters around
I would say OOP derives it’s view from a philosophical movement called realism, specially by Aristoteles view of reality (but not only).
Of course it’s complicated, but the idea behind Realism is that things actually exist in reality, that we can access through our thoughts to those things and we can perceive them exactly as how they are in reality.
Consider a tree. According to Aristotle, the tree is a substance. It exists independently of our thoughts or perceptions. Its “treeness” – the form – is what makes it a tree, not a rock or a cat. This form is an intrinsic part of the tree’s reality, not something imposed by our minds.
In OOP, when we create a class, we can think of it as defining a form. For instance, a class Tree
in a program defines what it means to be a tree in that program’s context – its attributes (like height, age, type of tree) and behaviors (like grow, shed leaves).
For further reading: Philosophical Realism
Extensions, especially when using types over classes, align with nominalism. This philosophy views general or abstract terms as mere labels for convenience rather than as representations of objective entities. In programming, this means defining types that describe structures without ascribing inherent existence or identity (a contrast to OOP’s approach). Nominalism suggests that we use terms like “bottle” for practical communication rather than as a true reflection of an entity’s essence.
For a deeper dive into nominalism: Nominalism in Metaphysics