A Simple, Functional Module Pattern for TypeScript

We’ve been using TypeScript with our React apps quite a bit recently. One common need that we have when programming in a functional style is a good pattern for creating small abstractions that can be used widely in an application.

These are the kind of things where you might traditionally use a class, but we wanted a pattern that would be:

  1. Based on simple data, not objects
  2. Functional – able to work well with Redux
  3. Easy to use, where the abstraction in use is clear
  4. Well-supported by editor autocomplete
  5. Able to lend itself to tree shaking with webpack.

The problem with classes is that they’re not simple data and don’t lend themselves to serialization. We want to use these abstractions in both our GraphQL API implementations and the client without having to add in conversion processes.

A class gives you a few things:

  • A type – a shape that TypeScript will check your code against
  • Operations on that type
  • A code pattern that groups operations with values of that type

After trying a few approaches, we settled on a pattern inspired from ML-like languages, such as F#. When we identify an abstraction we want to implement, we create an ES module for that abstraction that exports a type called Type and a set of functions and lenses for operating on that type.

We then always import * as ModuleName whenever dealing with that module. For example, a search-params.ts module representing faceted search parameters would be used as follows:

import * as SearchParams from 'core/search-params';
const simpleParams: SearchParams.Type = 
		SearchParams.fromSearchText("food")

By using import * as ModuleName with these modules, our use of the module is always prefixed when that abstraction is in use. This helps make our code more understandable.

Step-by-Step Example

Let’s look at a concrete example implementing the SearchParamsmodule above.

1. Define your type

We start by defining our shape with the name of the module, then exporting an alias called Type:

interface SearchParams {
  readonly query: string;
  readonly facets: FacetConstraints.Type;
  readonly page: number;
  readonly limit?: number;
}

export type Type = SearchParams;

This pattern helps with tooltips in users of the code. With this pattern, you often end up with functions from one module’s Type to another. Defining the interface with the module name ensures that signatures show as SearchParams -> SolrQuery instead of Type -> Type.

(Side note: facets is a FacetConstraints.Type – a type exported by another module. This pattern composes well.)

2. Export your API for dealing with this abstraction

When implementing a module in this way, we look at it as a black-box abstraction. We usually avoid using the type structure directly. Instead, we export constants, lenses, and functions for building and updating these types:


export const EMPTY: SearchParams = {
  query: "",
  page: 1,
  facets: FacetConstraints.EMPTY
};

export const query = Lens.from<SearchParams>().prop("query");
export const facets = Lens.from<SearchParams>().prop("facets");
export const page = Lens.from<SearchParams>().prop("page");
// ...

This module exports EMPTY search params and lenses that can be used to access and update substructure.

By exporting and using lenses rather than directly accessing SearchParams substructure, we end up programming to an abstraction rather than a data type. Lenses can be defined arbitrarily, allowing us to change our structure at will provided we don’t break the contracts of our lenses.

This same flexibility also allows us to create virtual properties just as we would with getters and setters on a class. We can compose lenses together or define custom lenses so that consumers of our module need not worry about actual versus derived structure.

Other operations

In our SearchParams case, we also export a number of utility functions, including constructors, validators, and serializers:

/** Check if a thing is a valid SearchParams.SearchParams */
export function isValid(aThing: any): aThing is SearchParams {
  // ...
}
/** Convert to a query string which can be put in the search page URL */
export function toQueryString(searchParams: SearchParams) {
	// ...
}

/** Attempt to convert a query string into a SearchParams.SearchParams. This can fail. */
export function fromQueryString(queryString: string): SearchParams | null {
	// ...
}

Since these operations are just functions in an ES module, webpack will tree-shake out most unused code.

Example use and editor support

As mentioned above, an import of this module might look like:

import * as SearchParams from 'domain/search-params';

Imported in this way, a function that deals with search params might look like:

function updateFacets(
  searchParams: SearchParams.Type,
  facets: FacetConstraints.Type
) {
  searchParams = SearchParams.facets.set(searchParams, facets);
  searchParams = SearchParams.page.set(searchParams, 1);
  return searchParams;
}

Qualifying module members with the * as Alias approach helps keep code readable, as what might otherwise be ambiguous free-floating function names are qualified. We can also create local aliases of the module in cases where we’re dealing primarily with one abstraction, e.g.:

const SP = SearchParams;
SP.fromQueryString(query);

Either way, we still get autocomplete support for dealing with our abstraction, such as:

Auto-Import with Visual Studio Code

One downside of this approach is that the TypeScript server’s import quick fix doesn’t usually do what we want, but we can still make use of it.

When we wish to make use of one of our abstractions, we’ll usually:

  1. Use a function or Type from a module in an unqualified way, such as var x: Type
  2. Put the cursor over the red squiggly and hit cmd-.. Code will let us pick the module from which we wish to import, as shown below.
  3. Update the reference with the qualified name: var x: SearchParams.Type
  4. Jump up to the import line added by Code and change { Type } to * as SearchParams.

Example import quick fix: