JavaScript & TypeScript video tutorial: Organizing your JavaScript project

Today I propose to share with you my organization for my JavaScript projects.

00:00 Introduction
01:00 Workspaces & “monorepo”
04:00 Avoid export default
09:12 Re-export dependencies
12:33 The component library
16:20 Organization “atomic design”
23:25 Next and the transpilation of modules
26:39 Share configuration
34:35 Testing
36:50 Turborepo

Workspaces

When working on a JavaScript project that involves several technologies, maintaining our source code in a single structure is not necessarily adequate and we quickly end up with a project that is too complex. We will seek to separate our code into several distinct applications that will be able to be managed independently (but we also do not want to create several separate projects because synchronizing them becomes too complex).

Fortunately for us, most package managers have a system of workspaces that will allow you to create several libraries within a single project (workspaces work with npm, yarn and pnpm). With this approach in mind, we will break down our project as follows:

  • A file apps which will contain all our applications.
  • A file packages which will contain libraries which will be shared by the different applications (functions, hooks, helpers, types…).

The “functions” package

In general, a package that I like to create is one that is going to contain utility functions that I am going to need throughout my application. These functions will be grouped according to the type of data they will process. For example I will have a file string.ts for functions that deal with strings. For all of my project I will use the language TypeScript that I now find essential to ensure good stability of the project and to make sure that I can carry out reorganizations with as much security as possible. On the other hand, since this library is intended to be used by the rest of my applications, I will create a file that will allow centralizing the exports and use it as an entry point in my package.json

Sample index.ts file

export {capitalize, isSimilar, slugify} from './string'
export {subDays, addDays, dateFormat} from './date'
export {reverse, numericSort} from './array'
// ...

And in my package.json

{
  "main": "src/index.ts"
}

Then this module can be included in my applications thanks to pnpm in my case.

{
  "dependencies": {
    "functions": "workspace:*" 
  }
}

End-to-end TypeScript

For my intermediate libraries I will not transpile my code to javascript and I will keep TypeScript as the entry point. It is my applications that will be responsible for transpiling the TypeScripts sources (vite, next, nuxt, nest…). In general, this approach works quite well but you will have to be careful with certain projects which sometimes do not transpile what is in the folder node_modules. In this case it will be necessary to make some adaptations in terms of configuration.

No “export default”

As much as possible I try not to use export defaultbecause this type of export allows you to import a module without necessarily imposing a specific name.

import Hello from 'library'

This can be problematic because depending on the files, and the developers, we can end up with the same module which will be imported with different names which can make it difficult to solve (find its origin). Named imports bring in my opinion more clarity on the origin of a method even if an alias is used.

import {Hello as maFonction} from 'library'

A simple search on maFonction finds all files that use it.

We re-export the third-party dependencies

Another important point when creating a “package” is to re-export the dependencies. For example, if in my application I want to use date-fns which contains a series of functions related to dates I will rather import it to re-export its methods.

export {capitalize, isSimilar, slugify} from './string'
export {subDays, addDays} from 'date-fns' // On peut faire un export * pour tout exporter

This may seem redundant but brings real added value when updating libraries. Indeed, if a method changes signature I could rewrite it and replace it in my library without affecting all of my applications.

export {capitalize, isSimilar, slugify} from './string'
export {subDays} from 'date-fns'
// addDays a changé mais je veux utiliser l'ancienne signature, j'ai donc réécris la fonction
export {addDays} from './date'

We will apply this approach as much as possible in order to limit the impact of changes to third-party libraries.

The “ui” package

If we are working on a project that requires front-end with a framework like React, VueJS, Svelte or other, we will generally start by creating a component library. To work on this library I use Storybook which allows to test these components in isolation. This tool can also be used to test components using a Snapshot system to detect regression.

The folder structure can depend on the project to reflect the organization made by the designers (but in general I am an Atomic Design structure).

/src
  index.ts
  /Atoms
    /Button
      Button.tsx
      Button.module.scss
      Button.stories.tsx
      Button.test.tsx
    /Card
    /Box
    /..
  /Molecules
    /SearchForm
    /WelcomeCard
    /..

As before we will find at the root the file index.ts which will take care of exporting the different modules and which will serve as an entry point for my application. Then I use filenames that match the names of my React components (this makes finding them faster with a search based on the filename). The stories and the tests will be placed in the same folder in order to group everything in the same place so as not to have to browse several architectures of folders at the same time.

The “tools” package

In order to ensure good code quality we will use a whole series of tools to control the code but we want all the modules to use the same configuration. To do this, we can create a “tools” package which will contain the various configuration files for the project.

For example for prettier, we can create a file prettier.config.js at the root of our module with the configuration that we want to see applied everywhere. Then in the project where I want to use prettier I just need to reference it in the file package.json.

{
  "prettier": "tools/prettier.config.js"
}

We can also do this for TypeScript configuration files. However, you will have to be careful because the configuration can vary from one project to another.

{
  "extends": "tools/tsconfig-next.json",
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules"]
}

Turborepo

Turborepo is a tool that will allow better control of monorepo-related processes and improve compilation performance. The principle is very simple, turbo will memorize a hash corresponding to the files of our different modules and only restart the tasks if the files have been modified. This avoids having to restart the tests of the entire application if only a part has been modified. Turbo can also use a centralized cache to keep the result of a build, for example, to retrieve it directly rather than restarting it. Its installation is simple and fits very well into an existing project.

pnpm install turbo --save-dev --workspace-root

Then we create a configuration file turbo.json

{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "dist/**"]
    },
    "lint": {
      "outputs": []
    },
    "dev": {
      "cache": false
    }
  }
}

Then in the package.json

{
  "scripts": {
    "build": "turbo run build",
    "dev": "turbo run dev --parallel",
    "lint": "turbo run lint",
  }
}

We can also use turbo to initialize a preconfigured project.