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!

Conversation
  • hasparus says:

    Great post! That’s super interesting approach.
    I’d usually domain specific types in a shared module (or a package in monorepo) and use Pick, Partial, Omit and Assign to derive my Input and Output types from them.

    • hasparus says:

      I’d usually define*
      🤦🏻‍♂️

    • hasparus says:

      I see upsides and downsides (e.g. types get harder to read) to both approaches though.

  • Andy Peterson Andy Peterson says:

    I’m glad you liked the post hasparus.

    I think there are definitely some potential downsides to this approach. For my team, the upsides have outweighed the downsides, but I’d be curious to see how this translates to another system.

  • Timur says:

    Hi, Andy! Thank you for this tutorial. My team and I are currently struggling with the overall project structure, so your post is right on time!

    I’m not sure I understand the second point right. You meant that `SnackConsumer` shouldn’t depend on `SnackCreator` response type, it should define its own input type instead, even if it will be duplication of some sort?

  • Morten Poulsen says:

    Hi Andy,

    Thanks for an interesting article. I (like hasparus) keep all my domain types in a shared module and then build my functions around them. It’s a more typical approach and kind of a DDD approach where the types are core. I fail to see the clear advantage with your approach (although I am genuine interested in other and better approaches than my own). But maybe you can clarify for me.

    For instance “Modules Export Only Functions, Not Interfaces” where you say: “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.”

    Here I would use the SnackCreator.createSnack factory function in the unit test and this way I can add fields to the SnackRecord type without changing the unit test. I would have to change the signature of the createSnack function before changes to the unit test was needed.

    With your approach I also feel that the types will be harder to read. It will be harder to understand the system merely by reading the types, whereas in a more typical approach with shared types, you get a much better understanding of what the system does.

    Am I completely off? :)

    Regards,
    Morten

  • Comments are closed.