@ganbarodigital/ts-lib-augmentations

Add additional features to a TypeScript type at runtime

Usage no npm install needed!

<script type="module">
  import ganbarodigitalTsLibAugmentations from 'https://cdn.skypack.dev/@ganbarodigital/ts-lib-augmentations';
</script>

README

Augmentations for Typescript

Introduction

Use this TypeScript library to add additional features to a TypeScript type at runtime.

Quick Start

# run this from your Terminal
npm install @ganbarodigital/ts-lib-augmentations
// add this import to your Typescript code
import { addExtension } from "@ganbarodigital/ts-lib-augmentations/lib/v1"

VS Code users: once you've added a single import anywhere in your project, you'll then be able to auto-import anything else that this library exports.

Concepts

What Are Protocols and Extensions?

An extension is functionality that has been added to a pre-existing type definition. A protocol is a description of an extension. We use protocols at compile-time to make sure a function or method parameter has the functionality we need, and we use protocols at runtime to detect and access optional functionality.

Here's a concrete example:

import path from "path";

/**
 * an example of a very basic type
 */
class Filepath {
    private path: string;

    public constructor(path: string) {
        this.path = path;
    }

    public getExtension() {
        return path.extname(this.path);
    }

    public valueOf(): string {
        return this.path;
    }
}

Imagine we are working with something like the Filepath type from the @ganbarodigital/ts-lib-data-locations library.

Let's say you want to add a new feature to Filepath. Maybe you want to use the file extension to work out what type of file it is pointing at, for example.

In the old days, you'd normally:

  • fork the repo on Github
  • add your own Filepath.getMediaType() to your fork
  • send a pull-request over to the original repo
  • wait to see if the pull-request ever gets accepted

Until all the steps are completed, it can block you from using your new feature in your own code. You might not be able to wait that long. And what do you do if your pull request is rejected or otherwise never gets merged? You end up maintaining your own fork, which is a whole new can of worms and pain.

To keep Filepath as tiny / reusable / maintainable as possible, we want the core type definition to have as little functionality as possible. We want extra functionality to permanently live somewhere else - in other modules that can be maintained separately and at their own pace.

This is where protocols and extensions come in.

How Do We Define A Protocol?

First of all, we need to describe the extension. We call this description a protocol. There's two parts to it:

  • interface(s) for compile-time checking
  • a constant containing information needed for runtime checking
/**
 * Protocol. An object that can tell us what media type it refers to.
 */
interface GetMediaType {
    getMediaType(): string;
}

/**
 * Optional Protocol. An object that might be able to tell us what
 * media type it refers to.
 */
type MaybeGetMediaType = {} | GetMediaType;

/**
 * this protocol definition tells us what is in GetMediaType
 *
 * we need something that exists at runtime, because the GetMediaType
 * interface only exists at compile-time.
 */
const GetMediaTypeProtocol = [ "getMediaType" ];

How Do We Use A Protocol Definition?

The first thing we can do with these definitions is to catch problems at compile-time. In this example, input must be an object:

  • that is both a Filepath, and
  • it must support the GetMediaType extension.

If it does not, the code will not compile.

// we use an intersection type to tell the compiler
// that we need our input to support multiple things
function isJson(input: Filepath & GetMediaType) {
    return input.getMediaType() === "text/json";
}

Design your code to catch as many problems as possible at compile-time. It can greatly simplify your code in the long-run.

Sometimes, that just isn't possible. Sometimes, we need to use a runtime check. Here's an example of that:

import { implementsProtocol } from "@ganbarodigital/ts-lib-augmentations/lib/v1";

// the `MaybeGetMediaType` type isn't for the compiler
//
// it's for fellow developers, as a form of documentation
function isJson(input: Filepath & MaybeGetMediaType) {
    // implementsProtocol() is a (very basic) type guard.
    //
    // it checks the `input` object, to see if it contains the functions
    // listed in the `GetMediaTypeProtocol`.
    if (!implementsProtocol<GetMediaType>(input, GetMediaTypeProtocol)) {
       return false;
    }

    // if we get here, we've convinced the TypeScript compiler that this
    // code will *probably* work
    return input.getMediaType() === "text/json";
}

How Do We Write The Code For An Extension?

To write an extension, you need:

  • an interface that extends:
    • the type you're extending, and
    • your protocol's interface
  • a class:
    • with the same name as the interface,
    • that contains the new method(s)

We need the interface so that this can use all the public methods and attributes of the type that you're extending. It uses TypeScript's declaration merging feature. That's why both the interface and the class need to have the same name.

Here's what our example GetMediaType extension would look like:

import mime from "mime-types";

interface FilepathGetMediaType extends Filepath, GetMediaType { }
class FilepathGetMediaType implements GetMediaType {
    public getMediaType() {
        return mime.contentType(this.valueOf());
    }
}

Now that we have an implementation, we can go back and rewrite our protocol definition:

// how to import into your own code
import { buildProtocolDefinition } from "@ganbarodigital/ts-lib-augmentations/lib/v1";

const GetMediaTypeProtocol = buildProtocolDefinition(FilepathGetMediaType.prototype);

There are pros and cons to using buildProtocolDefinition():

  • there's now a one-off runtime cost
  • it doesn't support any methods in base classes (use the slower buildDeepProtocolDefinition() for that)
  • but we no longer have to worry about our protocol definition getting out-of-step with our actual protocol design
  • and you don't have to worry about future changes to the ProtocolDefinition type

How Do We Use An Extension?

An extension needs to be added to your objects at runtime. It has to be added to every object after that object has been created.

import { addExtension } from "@ganbarodigital/ts-lib-augmentations/lib/v1";

function doSomething() {
    // we create a new Filepath type,
    // and then add in the `getMediaType()` function from the class
    const path = addExtension(
        new Filepath("/tmp/some-file"),
        FilepathGetMediaType.protocol,
    );

    // we can now do this, and it will safely compile
    return path.getMediaType();
}

That Seems Like A Faff

aka "Why can't we just patch the class's prototype and be done with it?"

What we're talking about here is doing something like this:

declare module "@ganbarodigital/ts-lib-data-locations/lib/v1/Filepath/Filepath" {
    interface Filepath {
        getMediaType(): string;
    }
}

Filepath.prototype.getMediaType = function() {
    return mime.contentType(this.valueOf());
};

That code changes the definition of the Filepath class, so that every new Filepath object automatically comes with the getMediaType() method. It's typically done as one include file, and there's no need to patch objects every time they are created.

On the face of it, it's a lot less effort than the protocols and extensions approach.

Unfortunately, it brings some significant long-term problems.

  • We can use the compiler to catch the times when we forget to patch the type itself. TypeScript's very good at handling that.
  • But the Filepath type itself now means different things, depending on whether or not it has been patched, and what patches have been applied.
  • And when you call a function or method that takes Filepath as a parameter, you've absolutely no way of knowing whether or not you need to provide a patched Filepath.

As folks like RxJS have found out, the end result is code that's fragile and confusing to work with. That's why they've been moving away from type patching in their recent work.

Compare that with protocols and extensions:

  • Every extension has a type, so once again we can use the compiler to catch the times where we forget to add an extension before using it.
  • We don't change the definition of Filepath. A Filepath is always a Filepath, no matter where you see it in the code.
  • If a function or method needs a Filepath that has a certain extension, that information is right there in the parameter type: Filepath & GetMediaType or Filepath & MaybeGetMediaType.
  • And the extension's interface and protocol definition aren't unique to Filepath. They can be reused - without modification - to create an extension for any other type that does the same thing.

A little bit of extra setup work gives us a lot of extra benefits. Our code is explicit, not just for the compiler, but for the developer too. Our concept becomes reusable, allowing us to do the Golang thing and move more towards consuming interfaces over explicit types. And the code remains simple, robust, and largely maintenance-free.

Programming By Feature aka The Golang Thing

Something Golang's standard library does exceptionally well is use interfaces everywhere instead of concrete types. Golang's standard library defines lots of very small interfaces - often containing only one or two methods. Both the standard library and userland code is written to accept and use these very targetted interfaces.

Once we're used to working with extensions and protocols, we can do the same in our TypeScript modules.

Here's the two examples from the beginning of this Concepts section. We've updated the parameter types in both examples to only ask for the extension that they need. We've basically dropped the Filepath requirement, because our examples were not relying on any of the Filepath functionality at all.

The end result? This code can now be applied to any type that supports the GetMediaType protocol without further modification. Our code just got a whole lot more reusable.

// we can use just GetMediaType here, because we don't need to know
// anything else about `input` for our code to work
function isJson(input: GetMediaType) {
    return input.getMediaType() === "text/json";
}
import { implementsProtocol } from "@ganbarodigital/ts-lib-augmentations/lib/v1";

// we can use `MaybeGetMediaType` here, because we don't need to know
// anything else about `input` for our code to work
//
// we're now using `MaybeGetMediaType` to tell the compiler what we need,
// as *well* as using it as documentation for developers
function isJson(input: MaybeGetMediaType) {
    // implementsProtocol() is a (very basic) type guard.
    //
    // it checks the `input` object, to see if it contains the functions
    // listed in the `GetMediaTypeProtocol`.
    if (!implementsProtocol<GetMediaType>(input, GetMediaTypeProtocol)) {
       return false;
    }

    // if we get here, we've convinced the TypeScript compiler that this
    // code will *probably* work
    return input.getMediaType() === "text/json";
}

API

ProtocolDefinition

// how to import into your own code
import { ProtocolDefinition } from "@ganbarodigital/ts-lib-augmentations/lib/v1";

/**
 * Contains a list of the methods that make up an Extension.
 *
 * This type is subject to change in the future. For forward-compatibility,
 * build it using `buildProtocolDefinition()`.
 */
export type ProtocolDefinition = string[];

Protocol is a value type. It contains a list of the methods implemented by an extension.

addExtension()

// how to import into your own code
import { addExtension } from "@ganbarodigital/ts-lib-augmentations/lib/v1";

/**
 * Turns `target` into an instance of the intersection type, by
 * adding `source`'s attributes and methods to the `target`.
 *
 * Pass in `<Source>.prototype` if you only want to add methods to
 * `target`.
 * Pass in a 3rd parameter - an instance of `<Source>` - if you also
 * need to copy attributes over to `target`.
 *
 * NOTE: returns the (modified) original `target` object.
 */
export function addExtension<Target, Source>(
    target: Target,
    source: Source,
    seed?: Source
): Target & Source;

addExtension() is a transform function. It copes any visible properties from each source onto the target, and then returns the modified target object as an instance of the intersection type.

buildProtocolDefinition()

// how to import into your own code
import { buildProtocolDefinition } from "@ganbarodigital/ts-lib-augmentations/lib/v1";

// our input parameters and/or return type
import { ProtocolDefinition } from "@ganbarodigital/ts-lib-augmentations/lib/v1";

/**
 * type factory. Builds a ProtocolDefinition.
 *
 * It will *NOT* pick up methods defined in parent classes. Use
 * `buildDeepProtocolDefinition()` for that.
 */
export function buildProtocolDefinition(input: object): ProtocolDefinition;

buildDeepProtocolDefinition()

// how to import into your own code
import { buildDeepProtocolDefinition } from "@ganbarodigital/ts-lib-augmentations/lib/v1";

// our input parameters and/or return type
import { ProtocolDefinition } from "@ganbarodigital/ts-lib-augmentations/lib/v1";

/**
 * type factory. Builds a ProtocolDefinition.
 *
 * This function supports:
 *
 * - getters and methods in your class
 * - getters and methods defined in your parent classes
 *
 * As a result, it will be slower than `buildProtocolDefinition()`. Use this
 * only where you definitely need the extra features.
 */
export function buildDeepProtocolDefinition(input: object): ProtocolDefinition;

implementsProtocol()

// how to import into your own code
import { implementsProtocol } from "@ganbarodigital/ts-lib-augmentations/lib/v1";

/**
 * type guard. Returns `true` if `input` has all the methods described
 * in `protocol`. Returns `false` otherwise.
 *
 * We check:
 * - that the methods all exist on input
 *
 * We do not check:
 * - that the methods have the right type signatures
 * - for Symbols
 */
export function implementsProtocol<T>(
    input: object & ({} | T),
    protocol: ProtocolDefinition,
): input is T;

implementsProtocol() is a type guard. Use it to prove to the TypeScript compiler that input does implement all the methods of interface <T>.

function mustMatchMediaType(input: MaybeGuessMediaType) {
    // NOTE - you must pass in the protocol as a generic parameter,
    // otherwse the type guard won't tell the compiler that
    // `input` is a `GetMediaType` (in this example)
    if (!implementsProtocol<GuessMediaType>(input, GuessMediaTypeProtoDef)) {
        return;
    }

    // if we get here, then `input` has what we need
    const parts = input.guessMediaType().parse();

    // ...
}

Q & A:

  • why doesn't addExtension() treat target as immutable?

    In a word: performance. In real-world uses, target is going to be the largest object to start with, and each source will typically only have one or two methods to be copied across.

    If we had to copy everything from target too, that would make addExtension() much more expensive, because we'd have to do a deep clone of target to make this work without surprises.

hasAllMethodsCalled()

// how to import it into your own code
import { hasAllMethodsCalled } from "@ganbarodigital/ts-lib-augmentations/lib/v1";

/**
 * data guard. Returns `true` if `input` has all the methods named in `names`.
 * Returns false otherwise.
 *
 * Supports methods inherited from parent classes.
 */
export function hasAllMethodsCalled(input: IndexedObject, names: string[]): boolean {
    return names.every((name) => input[name] && typeof input[name] === "function");
}

hasAllMethodsCalled() is a data guard. Use it to check if an object defines all the methods that you're looking to call.

NOTE that we don't check the type signature of the methods, only their names. This can still blow up in your face at runtime. Until JavaScript supports more comprehensive reflection, this risk can't be helped.

NPM Scripts

npm run clean

Use npm run clean to delete all of the compiled code.

npm run build

Use npm run build to compile the Typescript into plain Javascript. The compiled code is placed into the lib/ folder.

npm run build does not compile the unit test code.

npm run test

Use npm run test to compile and run the unit tests. The compiled code is placed into the lib/ folder.

npm run cover

Use npm run cover to compile the unit tests, run them, and see code coverage metrics.

Metrics are written to the terminal, and are also published as HTML into the coverage/ folder.