TypeScript Project Starter
March 19, 2020
I had been playing around with enough Node.js projects that I felt it was finally time to invest in a project generator. I recently finished a first pass of it and it’s already available for download on npmjs.com and GitHub. The generator is a project that will exist to bootstrap new projects by scaffolding directory structure and creating some boilerplate files from templates. Consider this introduction and the repo’s README your TL;DR.
For those looking for a deeper dive into what I’ve done, read on.
Yeoman Generator
That was a little vague, so let’s talk a bit more about the implementation. Yeoman is a tool that bootstraps new projects given a generator, which is essentially a plugin that programatically creates files and directories that a developer will then build upon.
There are built-in, official generators and a whole ecosystem of generators that others have provided. There are tons of good ones, especially for common Node.js projects like Express and React.
I hadn’t yet found a good one that exactly does what I’m currently playing with: server-side/backend projects that are a combination of TypeScript + executable output (via Pkg). So I took the opportunity to do it myself and write about it.
The Generator Package
Here’s what the repository currently looks like (it’ll probably be different by the time you read this). As you can see, the Yeoman generator itself is a Node.js (npm) package, with its own package.json
, lockfile, testing config, etc.
$ tree -a -L 4 -I '.git|node_modules|coverage' ./generator-ts-exe
./generator-ts-exe
├── .gitignore
├── .prettierrc
├── LICENSE.txt
├── README.md
├── generators
│ └── app
│ ├── index.js
│ ├── index.spec.js
│ ├── templates
│ │ ├── .babelrc
│ │ ├── .eslintrc
│ │ ├── .gitignore
│ │ ├── .prettierrc
│ │ ├── README.md
│ │ ├── jest.config.js
│ │ ├── package.json
│ │ ├── src
│ │ ├── tsconfig.json
│ │ └── webpack.config.js
│ └── tmp
├── jest.config.js
├── package.json
└── yarn.lock
What’s interesting about the structure is the highest entry point is at generators/app/index.js
, and it is not specified in package.json
by convention. The reason for this is that the generator, when installed globally, can be called as an argument to Yeoman’s CLI tool, yo
, which is installed globally as well. Running:
$ yo ts-exe
would default to the special path of generator-ts-exe/generators/app/index.js
. But if I had a generator-ts-exe/generators/subcommand/index.js
, then I could run:
$ yo ts-exe:subcommand
If you only had a single command associated with the generator then you could probably sidestep the generators/app
convention and just fix the files
property in the package.json
accordingly, but I’m keeping this structure in case I want to add subcommands later.
You can read the deeper explanation here.
The Generated Project
Now let’s look at the generator code and the templates it uses to bootstrap a new project. What I’m referring to is the contents of generators/app/
, so we’re only talking about this stuff with all the generator package files stripped away:
$ tree -a -L 3 ./generators/app/
./generators/app/
├── index.js
├── index.spec.js
└── templates
├── .babelrc
├── .eslintrc
├── .gitignore
├── .prettierrc
├── README.md
├── jest.config.js
├── package.json
├── src
│ ├── app.spec.ts
│ ├── app.ts
│ └── index.ts
├── tsconfig.json
└── webpack.config.js
index.js
is the entry point for running the generator - for now, it’s not complicated enough to be anything more than a single file. index.spec.js
is the unit test file for running Jest against. Everything in templates/
is a file that index.js
will copy over to the destination directory of the created project. Some of these files contain symbols that get picked up by Yeoman’s templating engine and have values injected in their place.
Generator Walkthrough
The generator code, i.e. contents of index.js
, is pretty straightforward. For the most part I went through the official guide and modified it to suit my needs. I’ll go through my own stripped-down code as another example.
So here’s index.js
, with // ... more x
denoting that there are more elements in the array that I don’t need to show:
const fs = require("fs")
const Generator = require("yeoman-generator")
module.exports = class extends Generator {
async prompting() {
this.answers = await this.prompt([
{
type: "input",
name: "title",
message: "Your project title (kebab-case)",
},
// ... more prompts.
])
}
writing() {
const { title } = this.answers
try {
fs.mkdirSync(`./${title}`)
} catch (err) {
this.log(`'${title}' already exists. Exiting.`)
process.exit(-1)
}
this.destinationRoot(`./${title}`)
// Copy files.
const files = [
".gitignore",
// ... more files.
]
files.forEach(v =>
this.fs.copy(this.templatePath(v), this.destinationPath(v))
)
// Copy templates with args.
const templates = [
["package.json", { ...this.answers }],
// ... more templates.
]
templates.forEach(v =>
this.fs.copyTpl(this.templatePath(v[0]), this.destinationPath(v[0]), v[1])
)
}
install() {
this.yarnInstall(
[
"@babel/cli",
// ... more dependencies.
],
{ dev: true }
)
this.yarnInstall(["debug"])
}
}
Yeoman will run through this exported module that extends the Generator class to run batched commands in different phases. The first phase is the prompting stage, where Yeoman can gather user-input. The only input I’ve shown is the title of the project, or the name of the npm package, which is usually kebab-case.
The second phase is where Yeoman will write files to disk. In this implementation we just do a basic check if a file with the same name as the given project title already exists. If the file does exist, then we just bail out. The existence check could be much smarter, but I’ve read that it’s common for Yeoman users to just do something like mkdir new-project && cd new-project && yo foobar
. So this is a happy medium for now.
If the file doesn’t exist, then we can proceed to create the directory, set its path as the root of the project (which is relative to where yo
is invoked), and copy app/templates/
into it at the top level. I split the files (that contain no templating symbols), from the templates because the API is a little different - the latter of which needs a third argument with the values to inject.
The third phase is where Yeoman will install the dependencies for the new project at runtime using the package manager of your choice (in my case, Yarn). And, naturally, I’ve separated my development dependencies from the normal dependencies.
You can hardcode fixed versions for packages by extending the package.json
with an object like this:
this.fs.extendJSON(this.destinationPath("package.json"), {
devDependencies: {
eslint: "^3.15.0",
},
})
but I don’t currently have a need for it.
If you had this cloned locally (and not globally installed from npm) right now, you could test it with:
# Install Yeoman as a global package to use the CLI.
yarn global add yo
# Go into the generator package root level and use NPM to link it globally,
# which makes it work as though it were a normal globally installed package.
cd ~/projects/generator-ts-exe && npm link
# Go to where you want to scaffold a new project and run the generator.
cd ~/projects && yo ts-exe
And that’s all there is to it! I may follow up this post with another discussing the templates in more detail - let me know if that’s something you would be interested in.
I'm currently a Software Engineering Manager (with a very generalist engineering background across embedded systems, robotics, and Frontend/UI) and I most recently worked at Cruise in the SF Bay Area. Welcome to my blog, where I write about tech, development, having a family, and other interests. You can follow me on X. Or check out my LinkedIn.