Quick Summary
Software development is great and fun but it can be a mess and a very bad influence if the developer experience is not given the importance and prioritized. Some standard development practices along with proper tooling can greatly improve this experience. This also aids in keeping the codebase clean and the repository in a good health. In this article, we will cover some development practices and essential tools to help you improve the development experience.
Audience
This article is essentially for JS developers and covers tooling around the JS ecosystem, however, the same concepts can be applied in other languages with the tooling available there. We will cover the below topics in this article.
- Code Linting using ESLint.
- Code Formatting using Prettier.
- Setting up Git Hooks for linting and code-formatting.
- Conventional Commits using Commitizen.
- Github Actions for CI/CD
Code-Linting
Code linting is very essential and important especially when you are working with an interpreted language like JavaScript. Linters are essentially static code analyzers it scans through your code without running it and flags any programmatic errors, stylistic errors, and any suspicious contracts. Properly setting up a linter in your codebase can help catch errors early on and ensure that some standard practices are being followed. This makes the codebase clean and helps in code reviews.
Linters can enforce code styling and rules like no unused variables or no console.log statements or no unused imports in fact it's a very powerful tool and can do much more than that.
Setting up Linters can be tedious and tiring but it brings great results once you adopt it and start using it properly in your team. I personally believe that linters and such tools should come with the language itself and developers shouldn't have to worry about setting one up. This experience is much more improved with deno (A secure runtime for JavaScript and TypeScript) which ships with a built-in linter and formatter for JavaScript and TypeScript.
Now that we have a basic understanding of linters let's see how we can set one up in a TypeScript project. We will be using ESLint which is very popular in the JS ecosystem and is fully pluggable. Every single rule in ESLint is a plugin, this means you can start off with a base configuration and then extend the rules to your needs by adding in more rules as a plugin.
Create a new folder and run npm init -y
to initialize your project, this will create a package.json
in the root directory with some stub fields.
Next, install the required dev-dependencies
into the project.
yarn add -D nodemon ts-node eslint typescript @typescript-eslint/eslint-plugin @typescript-eslint/parser
Let's first quickly setup TypeScript by running tsc --init
which will initialize the tsconfig.json
file in the project root, the only thing we are going to change is uncommenting outdir
option and change it to dist
"outDir": "dist"
Next, add a .eslintrc
file in the project's root directory. This is going to be the configuration file for eslint
where we can customize our rules. To quickly get started let's add a bare minimum configuration to the file.
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
]
}
Let's look at some of these options briefly, the root
key basically tells eslint to stop looking for configuration files in the parent directories. By default, eslint looks for config files in all parent folders until the root directory which can lead to unexpected results, plus this option can be very handy when you have a monorepo in place and each project uses a different configuration.
The extends
key lets you add the configuration you want to use for your project this can be a string that specifies a configuration or a path to a configuration file, here we are using two configurations first one is the eslint-recommended, and the next up is typescript-eslint recommended. You can also use airbnb-typescript config which is also very popular.
Now let's add some scripts in our package.json
to run our project in dev mode and a script for linting.
"dev": "nodemon --watch '**/*.ts' --exec 'ts-node' src/index.ts",
"lint": "eslint . --ext .ts"
And for the sake of testing our eslint setup let's create an index.ts
file and very simple for loop with an intended unused variable.
//src/index.ts
const range = 10;
for (let i = 0; i < 10; i++) {
console.log("i : ", i);
}
Now let's run yarn lint
and we will get a warning on our console.
This is great our eslint setup is working, but let's say we want to be more strict on our code base and want this to be an error and cause lint to fail, to do that head over to your .eslintrc
file and add the below rule.
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
"rules": {
"no-unused-vars": "error" // "error" | "warn" | "off"
}
}
Now if you run yarn lint
your console will display this as an error and lint won't succeed, also if you have the eslint extension installed on vs code it will highlight this issue.
We can also add another script to auto-fix the lint issues, note that not all issues can be auto-fixed and you can get a list of all the rules that can be auto-fixed here.
"lint-fix": "eslint . --ext .ts --fix"
That's pretty much for the linting setup, I hope you can now create a mental model for yourself and see how useful this tool can be if used properly.
Code Formatting
Let's accept it, seeing different code styles in a repo is a mess and if not controlled you can see all sorts of code conventions being followed in the repo. This is where code formatters come to our rescue, but before we jump into setting one up in our project we first need to understand that code formatters and linters are essentially not the same things, although there is a very thin line between them but they both serve a different purpose. Code-formatters essentially deal with the formatting of code and apply the code styling you have specified, they don't check code for potential bugs or issues and adhere to the styling.
Prettier is a very famous tool in the JS ecosystem and we will be setting it up in our project, with prettier we can enforce style rules like max-length, tab width, single-quotes, semicolon, etc. It's basically an opinionated code formatter that takes your code and parses it into an AST (Abstract Syntax Tree) discarding the original styling, after that it just pretty prints the AST with the code-styling you have specified.
Let get started, first we need to install the dev dependencies
yarn add -D prettier eslint-config-prettier eslint-plugin-prettier
Let's quickly explain the above dev-dependencies and what purpose do they serve.
-
prettier
: opinionated code-formatter. -
eslint-config-prettier
: used to disable all eslint rules that might conflict with prettier. -
eslint-plugin-prettier
: runs prettier as an eslint rule
Now create a .prettierrc
file in the root of your directory and add the below config.
//.prettierrc
{
"semi": true,
"trailingComma": "none",
"singleQuote": false,
"printWidth": 120,
"tabWidth": 2,
"arrowParens": "always",
"bracketSpacing": true
}
These rules specify to put semicolons at the end, remove trailing commas, use double quotes for strings, etc, you can read more about the rules here and set them up to your preference.
Now that we have the rules set up, let's add a script to format our code.
"format": "prettier --config .prettierrc 'src/**/*.ts' --write"
That's it we have configured prettier in our project, now whenever you run this script it will format all your source code in the src
directory according to the config you defined. Give it a test, use some single quotes or remove semicolons and then run yarn format
. You can also install the prettier vs code extension and set it to format on save.
Now that we have prettier setup let's configure it with eslint, update your .eslintrc
as below and you'll have prettier working with eslint and configured as a plugin in eslint config.
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "prettier"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"prettier"
],
"rules": {
"no-unused-vars": "error",
"prettier/prettier": "error"
}
}
Git Hooks
Cool, if you have been following till here this is where it gets the most exciting, you must be wondering it's great that we have set these tools up, but someone can still commit to the repo without running these scripts, this is where git hooks come to our rescue. Git hooks are just scripts that run automatically every time an event occurs in the repository. We will use a tool called husky that makes working with git-hooks easy.
Let's quickly install it as a dev dependency.
yarn add -D husky
// Also add a prepare script to your package.json as below.
"prepare": "husky install"
// Now run
yarn prepare
prepare
is an npm lifecycle script that will run on npm install
and this will ensure that whenever node modules are installed husky is installed too. Read more on life-cycle scrips here.
That's it, husky is now set up in our project, and we can configure it to run our lint
and format
scripts before commit, for that we can use the pre-commit
hook that runs before a commit. Let's add that hook now, head over to the terminal, and run the below command.
npx husky add .husky/pre-commit "yarn lint && yarn format"
You will notice that now there's a .husky
folder in the root of your repo and it contains a file pre-commit
with the below contents. This hook will run every time you commit to your repo.
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
yarn lint && yarn format
Try committing something in the repo now, and you will notice that lint
and format
scripts are executed first. If everything looks good, your commit will be added successfully, in case of issues it won't be committed to the repo and you'll have to fix those issues in order to make a commit. You should be able to make a mental model of this now, this ensures that no code with potential bugs or styling issues gets committed to the repo. This can help you save a lot of time in code reviews if these conventions are properly followed.
This configuration is great but if you notice there is one problem with this approach, if we have a very large codebase and we run linting and formatting on every commit in the code-base it can take up much longer and that's not the intended use case, we only want to run it on the files that have been staged for the commit because ideally only those files should be checked that are being committed to the repo, that's where another tool lint-staged
comes to our rescue and it will ensure that our pre-commit hooks only run on staged files. This can be configured very quickly too.
yarn add -D lint-staged
// Add below config to your package.json
"lint-staged": {
"src/**/*.ts": [
"yarn lint",
"yarn format"
],
},
And update your pre-commit
hook to run npx lint-staged
instead of running yarn lint && yarn format
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx lint-staged
That's it, now lint will only run against the staged files, you can quickly test it by creating a file in the repo with some lint-issues and don't stage it while staging some other files that are compliant with the lint rules and you should notice that git commit will run fine, but if you stage the file with the lint issues, it will block the commit and give you the lint errors on the terminal that should be fixed. If you want to lint the entire project you can still do it by running yarn lint
. Now our tooling is set up well enough to ensure that the whole team can follow it and adhere to the same coding standards and style guides.
Conventional Commits
Our development setup is pretty much complete but there's still one area where the team can adopt different conventions, that will be the commit messages yes that's right people can have different preferences when adding commit messages and we need to ensure that the team conforms to a standard convention. We will be adopting the conventional commits specification in our project and ensure it using a tool named commitizen
The general syntax for this specification is as follow
<type>[optional scope]: <description>
[optional body]
[optional footer(s)]
// Example
feat(landing-page): add new landing page
A new landing page for the website...
Closes #<github-issue-number>
Alright now that we have an understanding of the specification let's configure our project to use this tool.
yarn add -D commitizen cz-conventional-changelog
And add this to your pacakge.json
"config": {
"commitizen": {
"path": "cz-conventional-changelog"
}
}
"scripts": {
"cz": "cz",
...
}
Note: If you end up naming your script commit
then it might run twice because of this issue mentioned on the repo.
Now try committing to the repo by running yarn cz
and you will be prompted a couple of questions on the terminal to make the proper commit. Great now our conventional-commits are set up we can also set it up as a git hook by running the below command and running commitizen with our prepare-commit-msg
hook which is invoked by git commit
however there's an issue with this approach which triggers the git commit twice when running yarn cz
. The issue is opened on the repo here, I would advise not to use this approach until this issue is fixed and rely on the previous script yarn cz
npx husky add .husky/prepare-commit-msg "exec < /dev/tty && node_modules/.bin/cz --hook || true"
Github Actions
In the last part, we will focus on setting up a GitHub action to ensure that our lint and format jobs are run on every commit and how Github actions can help in our CI/CD pipeline. Setting up a good and fast CI/CD pipeline is very essential in modern software development now. As your software evolves it might become a very tedious task to compile the builds manually and if it needs to be compiled on multiple platforms you can imagine how time taking this could be.
Github actions are a handy tool to automate software workflows and provides CI/CD right out of your Github code repo. Github actions are event-driven and basically require a .yaml
file where you can provide your configuration in steps.
Some key terminologies to keep in consideration when working with Github Actions include
- Workflows: automated work procedures that contain one or more jobs.
- Events: an activity that triggers a workflow (e.g push to a branch)
- Jobs: a set of steps that execute on the same runner.
- Step: a task that can execute commands on a job.
You can read more about these on the official docs.
First, initialize a git repo in your project if you haven't already by running git init
and commit your changes to the repo. Make sure you add a .gitignore
file and add node_modules
and dist
to it so they don't get committed to the repo.
Next, create a repo on your Github account and copy the remote's origin url, now head over to your local repo and run
git remote add origin <remote-repo-origin>
git push -u origin <branch-name>
Next head over to your Github repo and click on the Actions tab and select set up a workflow yourself
In the editor name the file lint.yml
and clear the default action and replace it with.
name: Lint
on: [push]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup Node.js environment
uses: actions/setup-node@v2.4.0
with:
node-version: '14.16.1'
- name: Install Node Modules
run: npm install
- name: Lint and format
run: |
npm run lint
npm run format
This action file is pretty straight forward and you should be able to navigate through it very easily, we are declaring our workflow name as Lint that has a single job lint
. A workflow can have multiple jobs and by default, jobs run in parallel but can be configured to make them run sequentially by using the needs <job-id>
syntax in the .yml
file
Next, we are specifying to run the job on Ubuntu's latest version and set up Node v14.16.1 using a marketplace action. After that, we are just installing node modules and running our lint/format scripts.
Note this is a very simple example but you can extend it to your needs, for example, let's say you have set up tests in your repo then you can define another test
job that runs after linting
, and if the test job succeeds, you can run a build
job to compile a build and deploy to staging. You can basically configure it to adapt to your needs and automate your development workflow. Github actions are indeed a powerful tool and you should definitely explore them.
You can see all of this in action on my GitHub repo here
Conclusion
The idea with this blog was not to go into the depths of each tool but rather give you an overview of all these tools that can help you in our development process and ensure a better development experience. Setting up these tools can be a very boring and cumbersome task but these tools are your friends and once you adopt them properly in your code-base you won't regret it. Feel free to share your thoughts with me in the comment section or connect with me on Twitter.
Top comments (0)