Article summary
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:
- Based on simple data, not objects
- Functional – able to work well with Redux
- Easy to use, where the abstraction in use is clear
- Well-supported by editor autocomplete
- 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 SearchParams
module 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:
- Use a function or
Type
from a module in an unqualified way, such asvar x: Type
- 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. - Update the reference with the qualified name:
var x: SearchParams.Type
- Jump up to the
import
line added by Code and change{ Type }
to* as SearchParams
.
Example import quick fix: