#typescript

Loosely coupling with Typescript

Introduction

On this article we’ll go through a simple example that shows how to type the data you pass around on your application in a way that makes it easier to test and maintain.

About “loosely coupling”

In computing and systems design, a loosely coupled system is one in which components are weakly associated with each other, and thus changes in one component least affect existence or performance of another component

https://en.wikipedia.org/wiki/Loose_coupling

We’ll adjust our types and code to connect our components more loosely.

Example

Let’s say we have a chat application, we are dealing with messages, and we have a utility function that calculates the size of a list of messages.

// this usually lives on some other file and is imported here
interface MessageModel {
  id: string;
  author: { id: string; username: string };
  text: string;
  timestamp: number;
}

function getMessagesSize(messages: MessageModel[]): number {
  let size = 0;
  messages.forEach(message => size += message.text.length);
  return size;
}

We have the MessageModel interface defining the shape of our data, and then on the getMessagesSize function we receive a parameter of that type.

Simple enough, and it’ll work just as expected. But as you add more features and functions, and your code grows, using this approach makes it harder to maintain over time than it needs to be.

What’s the problem?

The function we just wrote only needs to use the text field, but it “knows” (through the parameter type) about all the other fields that a message has. And not only knows, it requires the other fields to be there.

Why is that a problem? You may ask. Let’s write a simple test for our function:

test("that the messages size is correct", () => {
  const messages = [
    { text: "hello" },
    { text: "world" },
  ];

  const size = getMessagesSize(messages);
  expect(size).toBe(10);
});

You’ll get this error:

error: TS2345 [ERROR]: Argument of type '{ text: string; }[]' is not assignable to parameter of type 'MessageModel[]'.
  Type '{ text: string; }' is missing the following properties from type 'MessageModel': id, author, timestamp

It basically says that you’re passing the wrong type to the getMessagesSize function.

In order to make it right, you need to write the test like so:

test("that the messages size is correct", () => {
  const messages = [
    {
      id: "test-message-id-01",
      author: { id: "test-uid-01", username: "username-01" },
      text: "hello",
      timestamp: 1234,
    },
    {
      id: "test-message-id-02",
      author: { id: "test-uid-02", username: "username-02" },
      text: "world",
      timestamp: 2345,
    },
  ];

  const size = getMessagesSize(messages);
  expect(size).toBe(10);
});

We have to write some “test data” just to construct a type that works with what getMessagesSize needs, when we only care about the text field.

Also, if the type changes in the future, we’ll have to update our test, even if the change is irrelevant for what we want to test and for the implementation of the function we want to test. For example either adding or removing fields, that are not the text field that we use on this function.

Different valid data shapes

Another problem we may face is that you may not be able to use the getMessagesSize function on different places of the app freely. A common use case is to use GraphQL APIs to get the data we need for the parts of the app we’re working on. So most likely the Message will look different on other parts of the app.

For example, on a message list you may care about the user avatars, but on a notifications icon you may only care about the number of messages.

How can we make this better?

Let’s change how we declare the type of data we expect to receive on our function.

interface Message {
  text: string;
}
function getMessagesSize(messages: Message[]): number {
  let size = 0;
  messages.forEach(message => size += message.text.length);
  return size;
}

Note that we don’t need to change the actual data that we send to this function, we are just saying that the data we expect needs to have a text field.

This new function signature will accept any message that has a text field. The message could have any number of fields or just text.

Because of how types work1 any “extra” fields will be accepted with no complaints.

Now we can write our test like so:

test("that the messages size is correct", () => {
  const messages = [
    { text: 'hello' },
    { text: 'world' },
  ]
  const size = getMessagesSize(messages);
  expect(size).toBe(10);
});

Not only this is simpler, but if the type changes in the future, we won’t have to update our test since we typed the getMessagesSize function with only what’s relevant.

Considerations

Note that since we are now using a parameter that only has what’s needed for the implementation, if eventually we need to update the function to use other fields that we may be passing, we’ll need to update the type/interface as well, even if the data we need is there, the shape we defined says otherwise, which in my opinion is a good trade-off since we are working with what we need.

Playground

Here’s the two code examples we discussed on the article, loaded on a Typescript playground for you to experiment:

Footnotes

  1. If you’re interested on how typescript matches types and data even if they are not exactly the same read about “structural typing” on the Typescript Handbook: https://www.typescriptlang.org/docs/handbook/type-compatibility.html.