DEV Community

Stefano Magni
Stefano Magni

Posted on • Edited on

How I ease the next developer reading my code

Recently, I jumped on the codebase of a small product of ours (an integration with Shopify) to implement some last-minute changes before going live. I had an overall idea of what the product does, and the external team that worked on it received:

Despite that, being effective since the first day was hard for me. Why? Because four of the most important details that get a codebase immediately readable were missing, which are:

  1. TypeScript’ Discriminated Unions instead of optional properties (helpful to describe the domain)

  2. Straightforward JSX code (ease reading and jumping through the code)

  3. Explicit State Machines (describe what the app does from an internal perspective)

  4. Cypress integration tests (tell what the app does from an external perspective)

I will explain why I weigh these four features in the following chapters.

1. TypeScript Discriminated Unions

Discriminated Unions (here is the link to the TypeScript documentation) are a potent tool to express why an object contains or does not have some properties. When applied to domain entities, they act as quick documentation that tells the readers about the domain entity they are dealing with. Here is an example

type Order = {
  status: string
  name: string
  description?: string
  at?: Location
  expectedDelivery?: Date
  deliveredOn?: Date
}
Enter fullscreen mode Exit fullscreen mode

link to the TS playground

Most of the Order’s properties are optional. It is fine. Let us look at one of the possible consumers of the above Order type.

export function createEmailMessage(order: Order) {
 if (order.expectedDelivery) {
  return `${order.name} will be delivered ${order.expectedDelivery}`
 }

 if (order.deliveredOn) {
  return `${order.name} has been delivered on ${order.deliveredOn}`
 }

 if (!order.expectedDelivery && !order.deliveredOn) {
  return `${order.name} is at ${order.at}`
 }
}
Enter fullscreen mode Exit fullscreen mode

link to the TS playground

TypeScript helps us avoid accessing properties that could not exist, and we are safe.

What are we missing in the above example? The whys.

  • Are we sure status is a generic string?

  • Why is description optional?

  • Why is at optional? Are we managing Orders that could not have an at Location? Such as services vs. goods?

  • Why is deliveredOn optional? Their name suggests that the Order has been shipped. Do they depend on the Order’s status?

We cannot answer these kinds of questions without reading the product/domain documentation (if any) or without interrupting and boring our colleagues to understand when/why the mentioned properties exist or not. Even worse, this uncertainty pours on the code, causing a big mess in the codebase that gets worse and worse as it evolves.

Then, it turns out that the status is not a generic string but one of a finite set of states, such as 'ready' | 'inProgress' | 'complete'. And the optional properties are not that optional, but they are bound to the Order’s status. It all makes sense, but how much time do I spend to get this info? What if there are 20ish entities whose types are like the Order one? It is what happened to me when I jumped on the project I mentioned earlier.

With the actual knowledge of the domain, let us refactor the Order type by leveraging TS Discriminated Unions.

type Order = {
  name: string
  description?: string
  at: Location
} & ({
  status: 'ready'
} | {
  status: 'inProgress'
  expectedDelivery: Date
} | {
  status: 'complete'
  expectedDelivery: Date
  deliveredOn: Date
})
Enter fullscreen mode Exit fullscreen mode

link to the TS playground

Now the future readers not only know when the properties exist but also why! And it is a highly concise documentation of the Order entity at the same time! I do not need to read anything but this type to get a precise idea of what the Order contains, and dealing with it gets a lot simpler. We moved from “oh, these properties are optional” to “oh, the order has three statuses, and when it is delivered, the delivery date is available”.

Do you remember the if-based createEmailMessage function? Now it’s

function createEmailMessage(order: Order) {
  switch(order.status) {
    case 'ready':
      return `${order.name} is at ${order.at}`

    case 'inProgress':
      return `${order.name} will be delivered ${order.expectedDelivery}`

    case 'complete':
      return `${order.name} has been delivered at ${order.deliveredOn}`
  }
}
Enter fullscreen mode Exit fullscreen mode

link to the TS playground

The returned message did not change, but understanding the correct message for every Order status is.

And the readers are not the only ones that benefit from such readability! TypeScript can better infer the returned type. Look at what TS can infer from the first version of the createEmailMessage type and the second one.

Image description

Image description

It means that consuming createEmailMessage turns from

const message = createEmailMessage(order)
if(message) {
  sendEmail(message)
}
Enter fullscreen mode Exit fullscreen mode

to

const message = createEmailMessage(order)
sendEmail(message)
Enter fullscreen mode Exit fullscreen mode

Multiply these benefits for the number of your entities and the size of your codebase… Do you get why I care so much about self-explanatory types? 😊

In the end, in the first Order type, we were missing the possibility of explaining the Order entity to the reader.

2. Straightforward JSX code

If I have not convinced you with the code of createEmailMessage, look at a standard JSX code.

export function RenderOrder() {
 const [order, setOrder] = useState<Order | undefined>()

 useEffect(() => {
  fetch('https://api.yourdomain.com/latest-order')
   .then(response => response.json())
   .then(order => setOrder(order))
 }, [])

 const onSendEmailClick = useCallback(() => {
  if (!order) return

  const message = createEmailMessage(order)
  if (message) {
   sendEmail(message)
  }
 }, [order])

 if (!order) return null

 return (
  <div>
   <p>
    {order.name} ({order.status})
   </p>
   {order.description && <p>{order.description}</p>}

   {!order.deliveredOn && order.expectedDelivery && (
    <p>Expected delivery: {order.expectedDelivery}</p>
   )}
   {order.deliveredOn && <p>Delivered on: {order.deliveredOn}</p>}

   <button onClick={onSendEmailClick}>Send email</button>
  </div>
 )
} 
Enter fullscreen mode Exit fullscreen mode

How many different DOM elements can result from the above JSX? Do you need less or more than five seconds to read it? Would you feel comfortable modifying it? And what about the React Hooks?

It is a simple example, and in a minute, you get an idea of whatever it does. But why should you waste this minute? And since it is just a simplified example, how long does it take to read a real-world component written like this?

The main problems are:

  • React Hooks are great, but they can make components’ code unreadable in no time.

  • The JSX handles too many cases. Building the states graph in your mind requires time.

My proposal is splitting the above component into many small components and custom hooks. Try reading the following code.

type FetchStatus =
  | {
      status: 'loading'
    }
  | {
      status: 'success'
      order: Order
    }

export function RenderOrder() {
 const fetchStatus = useFetchOrder() // Return FetchStatus

 if (fetchStatus.status === 'loading') return <p>Loading...</p>

 const order = fetchStatus.order

 switch (order.status) {
  case 'ready':
   return <ReadyOrder order={order} />

  case 'inProgress':
   return <InProgressOrder order={order} />

  case 'complete':
   return <CompleteOrder order={order} />
 }
}

type Props = {
 order: Order
}

export function CompleteOrder(props: Props) {
 const { order } = props

 if (order.status !== 'complete') return null

 const { name, description, deliveredOn } = order

 return (
  <div>
   <OrderHeading name={name} description={description} />

   <p>Delivered on: {deliveredOn}</p>

   <SendEmailButton order={order} />
  </div>
 )
}
Enter fullscreen mode Exit fullscreen mode

The crucial difference is clarity. You need more clicks to go down the rabbit hole to the exact component you are looking for, but clicks cost nothing when the code is straightforward. Instead, loading all the JSX states in our minds to guess what the component renders requires time and energy.

Please note: the if (order.status !== ‘complete’) return null in CompleteOrder is not optimal because we cannot add React hooks after the condition; hence we cannot have React hooks based on the properties of the completed Order. The problem, in this case, is that we know that the component will only receive a completed order, but TypeScript does not know. I don’t want to focus on this topic, but the quickest way to solve this problem is extracting a CompletedOrder type from the Order one:

type CompletedOrder = Extract<Order, { status: 'complete' }>

type Props = {
 order: CompletedOrder
}

export function CompleteOrder(props: Props) {
 const { order } = props
 const { name, description, deliveredOn } = order

 return (
  <div>
   <OrderHeading name={name} description={description} />

   <p>Delivered on: {deliveredOn}</p>

   <SendEmailButton order={order} />
  </div>
 )
}
Enter fullscreen mode Exit fullscreen mode

3. Explicit state machines

Everything is a State Machine. As programmers, we create State Machines in our minds before writing code. On the opposite, understanding the State Machines by reading the code is complex. State Machines’ details are hidden into the small components, hooks, functions, routes, atoms, stores, etc., that compose our app. It is a problem for the reader that cannot distinguish the “smart” parts of the app from the “dumb”/presentational ones:

  • Who does read the initial data (a lot of times more components)?

  • How does the app move from one state to another one?

  • Who does change the state of the app?

  • Who does react to every state change?

  • In one question: how does the app work?

It depends on the app’s architecture, but usually, all the details of the State Machines that were in the author’s mind are not explicit. Even the apps rigorously based only on a global store could fail to explain how the app reacts to every state change (see the “Redux is half of a pattern” article).

Describing the whole app with a single State Machine is hard, but splitting the features in State Machines is crucial to ease the reader’s job. It is not important how we describe the State Machines, but having an entry point in the code where we explain most of the app/feature high-level flows is.

The first example comes from the app I cited at the beginning of the article. The side effects were spread all over the app, with many points setting the (Valtio-based) atoms, causing the app to re-render part or most of the UI tree. The things that can impact what the users see are:

  1. If the users directly navigated to the app or the server redirected them, passing some data to the app

  2. If the users connected the app to Shopify

  3. If the users can view the app

  4. If the users logged in

  5. The intersection among the previous points

The React Hook that manages the state machine returns the following type (one status for every possible pages the users can see)

type AppStatus =
  // initial value
  | { status: 'idle' }
  | { status: 'showLogin' }
  | { status: 'showImport' }
  | { status: 'showConnect' }
  | { status: 'showNonAdminError' }
  | { status: 'loadingUserSession' }
  | { status: 'showGenericInstructions' }
  | { status: 'showAllOrdersSelectedError' }
  | { status: 'showSelectOrdersInstructions' }
Enter fullscreen mode Exit fullscreen mode

And the State Machine is a big useEffect composed by two nested switch statements with a code like this

switch (currentPage) {
  case 'connect':
    switch (howUserNavigated('connect')) {
      // ------------------------------------------------------------------
      // SCENARIO: the server redirected the user to the connect page
      // ------------------------------------------------------------------
      case 'sentFromServer':
        switch (connectStatus.status) {
          case 'notRequestedYet':
          case 'requesting':
          case 'failed':
            // when the connect succeeds, this effect is re-triggered
            setStatus({ status: 'showConnect' })
            break

          case 'succeeded':
            setStatus({ status: 'showSelectOrdersInstructions' })
            break
        }
        break

      // ------------------------------------------------------------------
      // SCENARIO: the user navigated directly to the connect page
      // ------------------------------------------------------------------
      case 'directNavigation':
        redirectTo('home') // as a result, this effect is re-triggered
        break
    }
    break
Enter fullscreen mode Exit fullscreen mode

You can argue that two nested switch aren’t great, but I see value in a single file where the reader can understand everything about the high-level, domain data-driven pages management. You can find the complete code of the Hook in this Gist.

The next step is describing the State Machine through something made on purpose like XState. Here is an example from my recent “How I strive for XState machine, types, and tests readability” article.

Image description

There is nothing more explicit than a centralized and viewable State Machine. More: Xstate allows you to create a working and UI-free prototype in no time.

Thanks to explicit State Machines, the reader can see how your app/feature internally works.

4. Cypress integration tests

We spoke about code and never about the app from a user perspective. But there are some crucial topics to show to effectively onboard new developers:

  • Describing what the app does from a User perspective: I want to show the UI, the code doesn’t matter.

  • Describe the order of the user actions and the interactions with the back-end.

  • Working against a controlled back-end

Here Cypress comes in handy with its ability to stub the back-end and its expressive APIs. Here is an video and an example of the code behind the tests


it('When the server sends the users to the connect page, should show the "connect" page', () => {
  visitAndResetSessionStorage('/connect?nonce=12345678')

  // --------------------------------------------------------------------------------------
  cy.log('--- Should bring to the connect page ---')
  cy.findByRole('button', { name: 'Connect' }).should('be.visible').click()

  // --------------------------------------------------------------------------------------
  cy.log('--- Should show an error if connect fails ---')
  cy.findByLabelText('Username').type('smagni', { delay: 0 })
  cy.findByLabelText('Password').type('smagni', { delay: 0 })
  cy.findByRole('button', { name: 'Connect' }).click()
  cy.findByText('Something went wrong (Error -1)').should('be.visible')

  // --------------------------------------------------------------------------------------
  cy.log('--- Should show the import orders instructions if connect succeeds ---')
  cy.intercept('POST', '**/shopify/connect', { fixture: 'importOrders/happyPath/connect' }).as(
    'connect-request',
  )
  cy.findByRole('button', { name: 'Connect' }).click()

  // --------------------------------------------------------------------------------------
  cy.log('--- Should pass the user data to the server ---')
  cy.wait('@connect-request').its('request.body').should('deep.equal', {
    nonce: '12345678',
    password: 'smagni',
    username: 'smagni',
  })

  // --------------------------------------------------------------------------------------
  cy.log('--- Should show the "select orders" instructions if connect succeeds ---')
  cy.findByRole('button', { name: 'Select orders' }).should('be.visible')
})
Enter fullscreen mode Exit fullscreen mode

By watching Cypress controlling the app and the app reacting to the interactions, it is straightforward to understand what the users can do and what they are not expected to do. The tests tell when the AJAX requests happen, thanks to the controlled server.

Conclusions

Now, I expect the next developers that must introduce a feature to:

  1. Watch the feature-related tests to comprehend what the app does.

  2. Read the feature-related tests to comprehend the data passed through the URL.

  3. What data the app sends to the server and when.

  4. What data the app receives from the server.

  5. Read and understand the state machine behind the mentioned flows.

  6. Quickly move through the React components.

  7. Find all the domain-related knowledge in the types.

  8. Blaming me because I don’t write code like them, but at least not spending roughly one week gathering all the domain knowledge I then made explicit through the code, the State Machine, and the tests 😊.

There are other important things to know for a long-living project, but the four I described are essential, in my opinion 😊.

Top comments (4)

Collapse
 
omril321 profile image
Omri Lavi

Hey Stefano, thank you for this insightful post! I agree with about all the features 😊

Regarding to TypeScript's type discrimination - I really like it and I try using it wherever possible. I find it a bit more difficult adding it to existing code, since it usually requires a broader refactor. Do you have tips and tricks of how to approach this issue?

Collapse
 
noriste profile image
Stefano Magni

Do you have tips and tricks of how to approach this issue?

Honestly: no. Things are particular hard when the possible values come from the server so the investigation process could take a lot... But I don't know shortcuts here 😞

Collapse
 
finnle profile image
Nguyen Le Hoang

amazing post!!!

Collapse
 
noriste profile image
Stefano Magni

I'm glad you liked it ❤️