All Articles

Introducing extensions to TypeScript

Accompanying repo here: https://github.com/FranDepascuali/extensions-ts

Introduction

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.

Inspiration

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.

Implementation

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:

  • We use namespace to group functions under a name, emulating the extension mechanism. The alternative, which would be directly exporting functions, can make discovery challenging. How do you know what to import from your module? By following the proposed convention, you’d always look for the pluralized version of the entity you’re extending. For example, typing Arrays. in your IDE would suggest all available functions from the Array extension.

Calling Array extension

A more detailed explanation of these design choices will follow in subsequent sections.

Properties of extensions

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:

  • Extensions are inherently linked to the existence of the entity they extend, without embodying an entity themselves.
  • An entity should be represented by a type from the type system.
  • Extensions are named as the pluralized version of the entity extended.
  • Extensions always receive the extended entity as their first parameter, along with any additional necessary parameters.
  • Extensions should be placed in their own file, typically <entity>.extension.ts, although this can vary at the implementer’s discretion.
  • Extensions should call upon other extensions when appropriate, emphasizing behaviors as extensions wherever feasible (instead of methods).
  • Extensions must be pure, ensuring that every function is a transformation without side effects.
  • Extensions can utilize properties or functions previously defined in the entity they extend.
  • Extensions are self-contained by definition.
  • Extensions are isolated by definition.
  • Extensions are shareable by definition.
  • Extensions are testable by definition.

Strategies for implementing extensions

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.

Namespace

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)

Abstract Class and Static Functions (deprecated)

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 });
	}
}
  • Why class?

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.

Prototype (deprecated)

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.

Object with functions (deprecated)

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/)

Extensions vs utils

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.

Extensions vs methods

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?

Embracing types and functions

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!

The recipee

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.

Design a new aspect of the system

  • Start with types and functions. I don’t know anyone who regrets using a function, but I know a lot of developers (included me, of course) that have regretted using classes.
  • Types and extensions are your tools: These tools are powerful and sufficient for most tasks. With them, you can go far without needing to learn a multitude of different concepts.

Dealing with Existing Classes

  • Turn methods into private free functions. Start by converting the methods of your class into private functions that aren’t bound to any particular instance. This helps decouple the logic from the class itself.
  • Call these functions from your methods. Once you’ve isolated the logic into free functions, call them from your class methods. This step helps you maintain the class’s interface while starting to shift the underlying implementation.
  • Transition away from classes, if possible. Over time, you might find that you can replace the class with a simpler, non-class solution.

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.

Where it takes us?

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.

FAQ

Why the term “extension”?

We use “extension” because it signifies adding new capabilities to an existing entity. It is not meant to be confused with inheritance.

Why use the pluralized version of what you’re extending?

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.

Do I need a special framework or something to implement it?

No, just TypeScript. This concept is adaptable to many languages, similar to Rust traits and Swift extensions.

Do I need to pass the whole list of dependencies to a function?

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’m interested in the philosophical background of OOP, what should I read about next?

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

I’m interested in what philosophical view of reality you relate extensions to?

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

Published Dec 28, 2023

Software Engineer, Mentor and Philosopher