6 Comments

Three Guidelines for Using the Functional Module Pattern for TypeScript

TypeScript is a wonderful language for designing data-driven applications. The type system is suited to describe the many different shapes and transformations of data as it travels from one end of a system to another.

As my team has gained more experience with TypeScript, we’ve developed some guidelines for creating typed modules that help the flow of data. They govern the design of modules, a pattern described here: A Simple, Functional Module Pattern for TypeScript.

These guidelines help us design fairly complex systems that can grow with the ever-changing business requirements for our project:

  1. Modules define interfaces for inputs and outputs right next to the function in which they are used.
  2. Modules export only functions, not their interfaces.
  3. Modules can import types/interfaces that represent core domain concepts. These core types are defined in a shared location, not in any particular module.

These guidelines came from months of iteration on best practices in code organization. In most cases, following these three rules alleviates a lot of the pain when new business requirements come into the project. They help ensure that no modules become too coupled to each other and contain their own responsibilities.

The source of most of the pain is bad abstractions. It’s really tough to make proper abstractions for software that is constantly developing with new requirements. I’ve experienced this especially when deciding when a TypeScript interface should be shared across a portion of the application.

1. Modules Defining Input/Output Interfaces

A module should define interfaces for each of its exported functions. Each interface is defined in close proximity to the function. Most of my modules end up following this pattern:


interface CreateSnackInput {
  name: string;
  color: Color;
}
interface CreateSnackResult {
  id: number;
  name: string;
  color: Color;
}
export function createSnack(input: CreateSnackInput): Promise<CreateSnackResult> {
  // Create a snack database record or something...
}

interface UpdateSnackInput {
  id: number;
  name?: string;
  color?: Color;
}
interface UpdateSnackResult {
  id: number;
  name: string;
  color: Color;
}
export function updateSnack(input: UpdateSnackInput): Promise<UpdateSnackResult> {
  // Update a snack...
}

I was inclined not to mention this step because it seems so simple, but this particular practice has been key for me when designing modules. Explicit inputs and outputs guide any changes made locally. I don’t have to worry about looking through different usages to see if types break.

2. Modules Export Only Functions, Not Interfaces

When using a module, explicit types are not necessary. Code becomes much cleaner if only the functional parts of the module are exposed. I’ve found this helps me make better abstractions of the data model.

For example, say I have a SnackConsumer module:


interface ConsumeSnackInput {
name: string;
}
export function consumeSnack(snack: ConsumeSnackInput): void {
console.log(`Eating ${snack.name}`);
// Do whatever is needed to consume a snack...
}

I can chain the functions from the SnackCreator to the SnackConsumer like so:


const newSnack = SnackCreator.createSnack({ name: "Apple", color: Color.Red});
SnackConsumer.consumeSnack(newSnack);

The type system operates completely fine here without being explicit about the types. The two modules guarantee that the output of one matches the input of the other.

In this case, newSnack technically has more properties than what’s necessary for consumeSnack. But, since the output of createSnack is structurally equivalent to the input of consumeSnack, the type system is satisfied.

However, what I find more important with keeping the interfaces private is the module design that is prevented. Let’s say we refactor the SnackCreator like this:


export interface SnackRecord {
  id: number;
  name: string;
  color: Color;
}

interface CreateSnackInput {
  name: string;
  color: Color;
}
export function createSnack(input: CreateSnackInput): Promise<SnackRecord> {
  // Create a snack database record or something...
}

SnackRecord is an accurate representation of what is stored for a snack. When writing a module like SnackConsumer, I’ve tried using the SnackRecord interface since the input is already similar to this type.


import * as SnackCreator from './snack-creator';

export function consumeRedSnack(snack: SnackCreator.SnackRecord): void {
  console.log(`Eating ${snack.name}`);
  // Do whatever is needed to consume a snack...
}

This doesn’t change the usage of the function that much, but it is a bit clumsier to use this module in general.

Let’s look at a unit test as an example. If we wanted to run this function in isolation for a test, we’d have to specify an entire SnackRecord:


describe('SnackConsumer', () => {
  it('can consume a snack', () => {
    SnackConsumer.consumeSnack({
      id: 12,
      name: 'Cookie',
      color: Color.Brown,
    });
    // assertions to make sure Cookie was consumed...
  });
});

If I add more fields to SnackRecord, I’d have to add to this test even though it doesn’t really affect the functionality of this module. The SnackConsumer is unnecessarily coupled to the results of the SnackCreator.

Having a shared interface does have a few upsides. For example, it’s much easier to rename a property for all instances of an interface across the codebase.

However, bloated types quickly outweigh any benefits of being able to refactor data models quickly. In my experience, keeping the simple, clean interfaces makes for a more understandable system.

3. Modules Importing Core Domain Concepts

Types don’t exclusively have to be local to module. There are plenty of opportunities to describe system-wide ideas with types. For example, my project uses a FindResult that looks like this:


export type FindResult<T> = Found<T> | NotFound;

interface Found<T> {
  found: true;
  value: T;
}
interface NotFound {
  found: false;
}

This type will ensure that the developer checks whether or not a find operation was successful. This generic type is applicable throughout an application, so defining it in a shared place for any module to import ensures consistent code patterns.

Other useful types to be shared across modules are enums, such as Colors from the examples up above.

Conclusions

I’ve found it really tough to make good abstractions of data models represented by TypeScript interfaces. By following these guidelines, I end up writing a lot of types, but all of them are simple, easy to understand, and most importantly, accurate.

If you find yourself getting lost in types or having difficulty trying to share interfaces across the codebase, try this module strategy. Have another strategy for creating proper abstractions? Let me know in the comments below!