Module Augmentation
Module augmentation is part of the process of adding your own functionality to @rbxts/expect
, but it can be a bit
confusing. This guide hopes to give you a better idea of why it's needed, and how it works.
- What module augmentation is
- How module augmentation fits in
@rbxts/expect
- How to use module augmentation
- How to define behavior for your augmented modules
- How to use your augented modules in other files
- How to organize your module augmentations to make it easier to maintain
Overview
Module augmentation is a feature provided by typescript that lets us extend (or augment) existing modules with additional context.
This feature is primarily used to define the structure of existing libraries that were not written in typescript- and as such, don't have proper types.
In a way, this is similiar to how we interop with luau in roblox-ts.
Another way to look at it is that module augmentation is a way to tell typescript "I know it doesn't look like this type has this, but trust me, it does."
And because we're so smooth, it believes us.
Defining augmented modules
To define an augmented module, we use the declare module
syntax, with an identifier that matches our import.
import { Assertion } from "@rbxts/expect";
// has the same identifer as our import: "@rbxts/expect"
declare module "@rbxts/expect" {
// specify the interface we want to augment
// notice that we also include the generic; this is a requirement of module augmentation
interface Assertion<T> {
// provide an outline of the method we want to augment
equal(other: unknown): this;
}
}
Module augmentation does NOT provide the functionality, it only tells typescript that it exists.
We'll learn how to provide the functionality in extending plugin behavior below.
Using augmented modules
Module augmentation is only linked to the file it's defined in. To properly use it in another file, that file needs to know about the module augmentation.
There are two ways to do this.
Side effects
Importing a typescript file, without using anything from said file, is called a side-effect import.
import "@rbxts/expect";
declare module "@rbxts/expect" {
interface Assertion<T> {
customEqual(other: unknown): this;
}
}
import { expect } from "@rbxts/expect";
import "./extensions";
expect(5).to.customEqual(5); // works!
This tells typescript to run the code in the imported file, but you don't need anything the file exports.
Since this causes typescript to run the module augmentation, this will allow it to be used in the file that imported it.
Definition files
Using definition files only allows you to define the type, it doesn't allow you to link the implementation.
As such, it's almost always recommended that you use side effects instead.
If you define your module augmentation in a definition file (.d.ts
), and add this to your typeRoots
array- you can
make your augmented module globally available.
import "@rbxts/expect";
declare module "@rbxts/expect" {
interface Assertion<T> {
customEqual(other: unknown): this;
}
}
{
"typeRoots": ["node_modules/@rbxts", "src/types"]
}
import { expect } from "@rbxts/expect";
expect(5).to.customEqual(5); // works!
Extending plugin behavior
Module augmentation only tells typescript how the function (or property) looks. It doesn't actually provide any behavior or implementation.
To provide the behavior, the module needs to export some way to add it themselves.
@rbxts/expect
does this through the extendMethods,
extendNOPs, and extendNegations functions.
import { CustomMethodImpl, extendMethods } from "@rbxts/expect";
// implement our actual method
const substringImpl: CustomMethodImpl<string> = (_, actual, str: string) => {
// ...
};
// tell typescript about our method
declare module "@rbxts/expect" {
interface Assertion<T> {
substring(str: string): Assertion<T>;
}
}
// link our method at runtime
extendMethods({
substring: substringImpl,
});
Behind the scenes, @rbxts/expect
keeps a global object of all the calls to
extendMethods- with properties mapping to the provided methods (eg;
"substring" -> substringImpl
).
Then, whenever someone calls expect, all those methods get manually added to the returned object.
Organizing augmentations
Importing your module augmentations in each file can be clumbersome, and is a bit too verbose for most people.
Thankfully, module augmentation also applies downstream; meaning you can create a centrialized place to run your side-effects (higher in the import chain).
Root index
The easiest way to do this is to import your extensions in your root index.ts
file.
import "./extensions";
// import all the extensions
import "./custom-equal";
import "./extra-negations";
import "./is-player-check";
This way, your modules are augmented at the root of your file- and all of the side effects run properly before any other code.
Test setup
If you have a setup process in your tests, or a collection of test/mock objects, then you can augment your modules in the same place.
/// <reference types="@rbxts/testez/globals" />
import { expect } from "@rbxts/expect";
import { pet as TestPet } from "@server/util/tests";
import { makePet } from "./actions";
export = () => {
describe("makePet", () => {
it("works correctly", () => {
const pet = makePet(TestPet.id);
expect(pet).to.deepEqual(TestPet);
});
});
};
import "@server/extensions";
export const pet = {
id: "1",
name: "Cat",
attack: 5,
health: 100,
speed: 30,
};
// import all the extensions
import "./custom-equal";
import "./extra-negations";
import "./is-player-check";
Summary
Let's recap what we've learned about module augmentation:
- They provide a way to define functionality for other modules
- They have to be added to expect through one of the extend methods
- They have to be linked at runtime by importing the file