Codemods for React and TypeScript
A codemod is an automated way of making modifications to code. I think of it as find and replace on steroids.
Codemods are helpful for library maintainers because they allow them to deprecate and make breaking changes to the public part of the API while minimising upgrade costs for developers consuming the library.
For example, Material UI has the following codemod for updating to version 5.
npx @mui/codemod v5.0.0/preset-safe <path>
This post covers creating codemods for a reusable React and TypeScript component library.
jscodeshift
jscodeshift
is a library built by Facebook to help create transforms. A transform is code that will change consuming code to our library to target a new version.
To install jscodeshift
and the TypeScript types, we run the following commands in a terminal:
npm install jscodeshift
npm install --save-dev @types/jscodeshift
AST Explorer
Transforms are based on the code’s abstract syntax tree (AST). AST Explorer is a website that helps us understand the AST for some code.
To configure AST Explorer for React and TypeScript, we select the typescript parser on the toolbar:
Configuring TypeScript
In tsconfig.json
, it is important to exclude the test fixture files using the exclude
field (more on test fixtures later). We also specify the location for the transpiled transforms using the outDir
field:
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "node",
"esModuleInterop": true,
"target": "esnext",
"strict": false,
"jsx": "preserve",
"lib": ["es2017"],
"outDir": "dist" },
"exclude": ["transforms/__testfixtures__/**"]}
Configuring ESLint
If using ESLint, we again need to ignore test fixtures using the ignorePatterns
field:
{
"root": true,
"env": { "node": true },
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"extends": ["plugin:@typescript-eslint/recommended", "plugin:prettier/recommended"],
"ignorePatterns": ["**/__testfixtures__"], "rules": {
"@typescript-eslint/explicit-function-return-type": "off"
}
}
Creating a transform to change the name of a component prop
We will create a transform to change a kind
prop on a Button
element to variant
. We will call the transformation button-kind-to-variant
.
File structure
All the transforms will be in a transforms
folder. The tests will be in a __tests__
folder within the transforms
folder. Here’s what the file structure looks like:
transforms
└── button-kind-to-variant.ts // the transform
└── __tests__
└── button-kind-to-variant.ts // test for the transform (references test fixtures below)
└── __testfixtures__
└── button-kind-to-variant
└── basic.input.tsx // Code snippet to test
└── basic.output.tsx // expected result test code snippet
Creating a test
We’ll write a test before writing the transform. This will make it clear what the transform needs to do. Let’s start with the test fixtures, which are just input and expected output code snippets:
// basic.input.tsx
import { Button } from "@my/reusable-components";
function SomeComponent() {
return <Button kind="round">test</Button>;
}
// basic.output.tsx
import { Button } from "@my/reusable-components";
function SomeComponent() {
return <Button variant="round">test</Button>;
}
So, we expect the kind
prop to change to a variant
prop on the Button
element.
Note: If you are using prettier to format code automatically, it is advised to exclude the test fixture files so that you can tweak their format to match what comes out of jscodeshift
:
// .prettierignore
**/__testfixtures__
The test code for a transform is fairly generic and creates a test for each test fixture. The bits that change are the variable for the transform name and the array of test fixture names:
// button-kind-to-variant.ts
jest.autoMockOff();
import { defineTest } from 'jscodeshift/dist/testUtils';
const name = 'button-kind-to-variant';const fixtures = ['basic'] as const;
describe(name, () => {
fixtures.forEach((test) =>
defineTest(__dirname, name, null, `${name}/${test}`, {
parser: 'tsx',
}),
);
});
Creating the transform
Here’s the start of our transform:
import { API, FileInfo, JSXIdentifier } from 'jscodeshift';
export default function transformer(file: FileInfo, api: API) {
const j = api.jscodeshift;
const root = j(file.source);
// TODO find Button JSX elements
// TODO find `kind` prop
// TODO change `kind` to `variant`
return root.toSource();
}
jscodeshift
expects a function as the default export to do the transformation. The function takes in information about the source file and the jscodeshift
API. The jscodeshift
API can query the source file and make changes to it.
Before writing the transformation code, we can use AST Explorer to get a feel for the AST structure. Not surprisingly, Button
is a JsxElement
:
… and the kind
prop is a JsxAttribute
:
Here’s the full transform function:
export default function transformer(file: FileInfo, api: API) {
const j = api.jscodeshift;
const root = j(file.source);
// find Button JSX elements
root .findJSXElements('Button') // find `kind` prop
.find(j.JSXAttribute, { name: { type: 'JSXIdentifier', name: 'kind', }, }) // change `kind` to `variant`
.forEach((jsxAttribute) => { const identifier = jsxAttribute.node.name as JSXIdentifier; identifier.name = 'variant'; });
return root.toSource();
}
The findJSXElements
method is used to locate all the Button
elements. For the found Button
elements, the find
method is used to get the kind
attribute. The find
method returns a collection of matching items, so we use the forEach
method to iterate through this collection. We then change the attribute name to 'variant'
.
Running the test
We run jest
to run the test. So, we can add this in the test
script in package.json
.
{
"scripts": {
"test": "jest", ...
},
}
Running npm test
will then run the test:
Our test passes. 😊
Running the codemod
Before running the codemod, we need to transpile the transform into JavaScript. We can do this using a build
script in package.json
, which calls the TypeScript compiler, tsc
:
{
"scripts": {
"build": "tsc", ...
},
}
Running npm run build
will put the JavaScript version of the transform in a dist
folder.
To run the codemod, we need to run the jscodeshift
CLI. We can put in a codemod
script in package.json
to do this:
{
"scripts": {
"codemod": "jscodeshift",
...
},
}
Usually, the codemod will be executed on code in a separate project. In this example, we will execute the codemod on a file in this project at src\HomePage.tsx
. Running the following command will execute a dry run of the transform on the HomePage.tsx
file:
npm run codemod -- --parser=tsx -t dist/button-kind-to-variant.js src/HomePage.tsx --print --dry
Here’s an explanation of the parameters:
--parser=tsx
means that the TypeScript parser is used to parse the files the codemod is being applied to. We need to specify this because the default parser is babel.- The file after
-t
specifies the transform file. This isdist/button-kind-to-variant.js
in our example. - The file or directory after the transform path specifies the files the codemod should be applied to. This is
src/HomePage.tsx
in our example. --print
(or-p
) specifies that the transformed files are outputted to the terminal.--dry
(ord
) specifies that just a dry run will happen (rather than changing the source files).
So, running the command prints out what the updated source code would be:
The updated source is exactly what we require. 😊
Running the following command can be executed to run the transform and perform the update:
npm run codemod -- --parser=tsx -t dist/button-kind-to-variant.js src/HomePage.tsx
Nice. 😊
This example codemod is on my GitHub
Did you find this post useful?
Let me know by sharing it on Twitter.If you to learn more about testing React apps, you may find my course useful: