Organizing TypeScript modules
TypeScript supports different ways of importing values into another module. One of the way we're going to talk about here is the "namespace import" syntax.
TypeScript supports different ways of importing values into another module. One of the way we're going to talk about here is the "namespace import" syntax. Consider the following snippet:
// foo.ts
export const A = "A";
const B = "B";
// index.ts
import * as Foo from 'foo'
Foo.A // valid
Foo.B // invalid
Foo
in this case will contain everything that is exported from the module foo
, which is currently only one variable A
.
After coming across this article from Drew Colthorp, we've looked at how ML languages module systems such as Reason were structured and how TypeScript libraries like fp-ts were designed. We wanted something similar because we found it to be easy to read and reason about. Our approach is simple: we've identified sets of abstractions that could relate to the same module (or type) and created functions to operate on it. In other words, we've grouped functions that work on a type and exported them from a module which will be imported as a "namespace".
Here's a concrete example we're currently using. Reading and writing search filters from the URL when you're doing a search on Unsplash: https://unsplash.com/s/photos/bear?orientation=landscape&color=black_and_white.
The module looks a lot like the following minus some code that I've stripped away which wasn't relevant for our example:
//Filters.ts
import * as O from 'fp-ts/Option';
import * as EQ from 'fp-ts/Eq;
import {flow} from 'fp-ts/function';
import * as Bool from 'helpers/boolean';
enum Orientation {...}
type ColorId = ...
export type Filters = {
orderBy: Order;
color: O.Option<ColorId>;
orientation: O.Option<Orientation>;
};
const Eq: EQ.Eq<Filters> = EQ.getStructEq({
orderBy: EQ.eqString,
color: O.getEq(EQ.eqString),
orientation: O.getEq(Eq.eqString),
});
export const DEFAULTS: Filters = {
orderBy: Order.Relevant,
color: O.none,
orientation: O.none,
};
export const equal = Eq.equals;
export const notEquals = flow(Eq.equals, Bool.not)
export const fromQuery = (query: Query): Filters => ({
orderBy: pipe(
R.lookup("order_by", query),
O.getOrElse(() => DEFAULTS.orderBy),
),
color: R.lookup(FilterQueryParam.Color, query),
orientation: R.lookup("orientation", query),
});
...
This module contains the main type Filters
and a set of functions to operate on filters (equality check, creating filters from other types, etc...). Then we'd import this module using a namespace import which would give a natural context to what we're currently doing:
import * as Filters from 'filters';
const query: Query = {...}
const filters = Filters.fromQuery(query)
const filters2 = Filters.DEFAULTS
Filters.equals(filters, filters2)
// vs
import {fromQuery} from 'filters'
const filters = fromQuery(query)
The idea is to leverage contextual naming through the namespace to understand in which context a given operation is happening. One alternative would be to use named imports directly and it might be fine for small files but when you have a lot of modules we found ourselves creating really complicated names to make sure some context was preserved such as createFiltersFromQuery
. This is where we think namespace imports really shine, it keeps names simple and gives good context on operations.
Caveats
Now you might be wondering, it can't just be all nice and shiny, can it? It does have a few trade offs but we thought the pros outweigh the cons in our case.
First, I'm happy to mention that namespace imports do not contribute to a bigger bundle size, they treeshake correctly like the following rollup configurations suggest:
Now the cons. It does have some impact on the developer experience (DX). If you're using VS Code, it won't be able to automatically import the namespace for you. We are relying on VS Code snippets to speed it up a bit for us. My teammate Oliver has also wrote a piece about how we can create named namespace imports but this isn't quite ready for us to use (mainly because we are not using the latest webpack version).
Conclusion
- Provides context around operations
- Helps organizing abstractions around types
- Treeshaking friendly
- Simplifies function naming