Documented TypeScript Template to Speed Up New Project Setup
CodeSandbox.io
·
Report a Bug or Request a Feature
Features
- 🧩 Some Useful Commands
- ☑️ Strict TypeScript Configuration
- ✅ Static Code Analysis with ESLint
- 🎨 Consistent Formatting with Prettier
- 📜 Import Order Rules
- 👨💻 Automatic Process Restart During Development With Nodemon
- ✨ Use New JavaScript Features In Older Node Versions
- ⛓️ Root Imports
- 🧪 Jest For Running Tests
- 💂 Import Guard for "_internal" directory
- 🌐 .env files & Type Safe Environment Variables
- 📦 Compatible with npm, yarn & pnpm
- ⚙️ Recommended VS-Code Settings & Extensions
- 📝 Consistent Commit Messages with Commitizen
- 🧱 A Simple Build Pipeline
This project is configured as a Github template. You can create new repositories using this template by clicking on Use this template
button or this link.
git clone https://github.com/CertainlyAria/typescript-starter.git new-project
cd new-project
rm -rf .git
git init
git clone https://github.com/CertainlyAria/typescript-starter.git new-project
cd new-project
git remote remove origin
Out of the box, you can use this template with node v20.6.0
and above. If you are on node v20.5.1
or below, open package.json
and replace all 3 instances of --import tsx/esm
with --loader tsx/esm
These commands are defined in package.json
file, inside the scripts
object. You can run each command by prefixing it with npm run
. For example: npm run dev
or npm run build
. Some commands require additional parameters.
Runs src/index.ts
in watch mode. When you change a file, the old process will get killed & src/index.ts
runs again.
See Automatic Process Restart During Development With Nodemon for more info.
Creates a production build. See A Simple Build Pipeline for more info.
Runs your tests in watch mode. When you change a file, the tests will run again.
See Jest For Running Tests for more info.
Runs all of your tests once.
See Jest For Running Tests for more info.
Checks formatting of all files using Prettier. See Consistent Formatting with Prettier for more info.
Formats all files using Prettier. See Consistent Formatting with Prettier for more info.
Checks linting issues in all files using ESLint. See Static Code Analysis with ESLint for more info.
Checks for type errors in all files.
From the TypeScript Docs:
The strict flag enables a wide range of type checking behavior that results in stronger guarantees of program correctness. Turning this on is equivalent to enabling all of the strict mode family options.
⚠️ WARNING: Future versions of TypeScript may introduce additional stricter checking under this flag, so upgrades of TypeScript might result in new type errors in your program. When appropriate and possible, a corresponding flag will be added to disable that behavior.
You can find the rules in tsconfig.json
, inside compilerOptions
object, under // Code quality
section.
For example, if you want all the strict rules but you want to allow fall through in switch statements, set noFallthroughCasesInSwitch
to false
.
TypeScript's main goal is detecting type issues. Lots of other code quality issues fall outside of the scope of TypeScript. But you may want to enforce these checks in your project. This is where ESLint comes into play.
From the ESLint Homepage:
ESLint statically analyzes your code to quickly find problems. It is built into most text editors
You can find the rules in .eslintrc.json
, inside rules
object.
For example, if you don't want ESLint to warn you when you use the var
keyword, change no-var
from "warn"
to "off"
.
"no-var": "warn",
:
function add(x: number, y: number) {
var result = x + y; // 🟡 ESLint: Unexpected var, use let or const instead.
return result;
}
"no-var": "off",
:
function add(x: number, y: number) {
var result = x + y; // No problem is reported
return result;
}
You should not worry about code style & formatting. Tools such as prettier already handle that.
Prettier handles formatting in this project. You can customize code style from .prettierrc.js
file.
There is also a .prettierignore
file which excludes files inside certain directories from being formatted.
Additionally, some stylings rules are configured inside .eslintrc.json
. These rules come from Prettier Plugin for ESLint. In .eslintrc.json
, inside "extends" array, the last entry is plugin:prettier/recommended
As the project size grows, so does the number of imports. Grouping import
statements together can make distinguishing various import types easier.
comments won't be present to the source code.
//side effect imports
import "~/styles/index.css";
//imports from npm packages
import { appWithTranslation } from "next-i18next";
import type { AppProps } from "next/app";
import { useEffect, useState } from "react";
import { QueryClientProvider } from "react-query";
//imports from other code written in this project
import { Layout } from "~/modules/layout";
import { useLocale } from "~/modules/use-locale";
import { useTheme } from "~/modules/use-theme";
These rules are enforced using @ianvs/prettier-plugin-sort-imports.
In .prettierrc.js
, you can find the configuration for @ianvs/prettier-plugin-sort-imports
plugin. If you want to disable this, remove the plugin from plugins
array and from devDependencies
in package.json
.
From DigitalOcean.com:
In Node.js, you need to restart the process to make changes take effect. This adds an extra step to your workflow. You can eliminate this extra step by using nodemon to restart the process automatically.
From nodemon docs:
nodemon is a tool that helps develop Node.js based applications by automatically restarting the node application when file changes in the directory are detected.
In this project, nodemon is configured to transpile TypeScript files on the fly using Babel before running them. You can find nodemon
's configuration in nodemon.json
file.
Sometimes you cannot use latest Node release. For example you may not have the permission to update node in the server. Under these scenarios, you can still use some of recent JS features. Under the hood, your code will get transpiled to older syntax.
Babel & preset-env babel plugin are used to transpile JavaScript. Configuration for these tools is in babel.config.js
file.
Under the hood, preset-env
uses another tool called browserslist to determine which syntax & features each environment supports. You can find the browserslist
configuration in package.json
file, inside browserslist
object. Multiple keys allow you to specify different targets for development & production.
"browserslist": {
// when NODE_ENV is set to "production", transpile source code to support node 16
"production": [
"node 16"
],
// when NODE_ENV is set to "development", transpile source code to support node 17.9
// fewer transpiles increases speed during development
"development": [
"node 17.9"
]
},
Import paths can get very long & ugly in JavaScript/TypeScript. With root imports you can use:
import 'foo' from '~/modules/foo/bar.js';
instead of:
import 'foo' from '../../../../../modules/foo/bar.js';
In tsconfig.json
, inside compilerOptions
object, baseUrl
& paths
are configured. This will allow correct resolution in code editors & ESLint. However TypeScript compiler DOES NOT rewrite the paths during compilation. See:
microsoft/TypeScript#10866,
microsoft/TypeScript#16640 &
microsoft/TypeScript#26722.
For correct output, another tool is required. Corrently rewriting tasks is done using module-resolver
babel plugin. You can find module-resolver
's configuration in babel.config.js
, inside plugins
array.
⚠️ Warning:baseUrl
&paths
fromtsconfig.json
should be synchronozied withroot
&alias
inbabel.config.js
From Jest's Website:
Jest is a JavaScript testing framework designed to ensure correctness of any JavaScript codebase. It allows you to write tests with an approachable, familiar and feature-rich API that gives you results quickly. Jest is well-documented, requires little configuration and can be extended to match your requirements.
Jest works out of the box with babel. You can use the jest.config.ts
file in this project to further customize Jest.
I use this pattern to prevent depending on implementation details.
We want to validate an object against a schema.
💩 Initial Implementation
// project structure
src
│ index.ts
└───modules
└───validate-user
│ index.ts
│ user-schema.ts
// src/modules/validate-user/user-schema.ts
import * as Yup from "yup";
const userSchema = Yup.object({}).required().shape({
id: Yup.number().integer().positive().required(),
name: Yup.string().required(),
});
export { userSchema };
//src/modules/validate-user/index.ts
import * as Yup from "yup";
import { userSchema } from "./user-schema";
function isValidUser(data: unknown): data is Yup.InferType<typeof userSchema> {
return userSchema.isValidSync(data);
}
💣 While this works, there is a catch.
src/index.ts
can depend on src/modules/validate-user/user-schema.ts
. Nothing prevents src/index.ts
from doing this:
// src/index.ts
import { userSchema } from "./modules/validate-user/user-schema";
const data = { name: "Aria" };
if (userSchema.isValidSync(data)) {
// ...
} else {
// ...
}
This problematic because userSchema
is implementation detail of validate-user
module.
Let's say one year from now, validate-user
replaces Yup
with Zod
.
// src/modules/validate-user/user-schema.ts - ONE YEAR LATER
import { z } from "zod";
const userSchema = z.object({
id: z.number().int().positive(),
name: z.string(),
});
export { userSchema };
//src/modules/validate-user/index.ts - ONE YEAR LATER
import { z } from "zod";
import { userSchema } from "./user-schema";
function isValidUser(data: unknown): data is z.infer<typeof userSchema> {
const { success } = userSchema.safeParse(data);
return success;
}
💥 src/index.ts
is broken now. Because it dependend on a Yup schema, instead of relying on isValidUser(data: unknown)
function
// src/index.ts - ONE YEAR LATER
import { userSchema } from "./modules/validate-user/user-schema";
const data = { name: "Aria" };
// ❌ Property 'isValidSync' does not exist on type 'ZodObject'
if (userSchema.isValidSync(data)) {
// ...
} else {
// ...
}
✅ Better Implementation
// project structure
src
│ index.ts
└───modules
└───validate-user
│ index.ts
└─── _internal
│ user-schema.ts
// src/modules/validate-user/_internal/user-schema.ts
import * as Yup from "yup";
const userSchema = Yup.object({}).required().shape({
id: Yup.number().integer().positive().required(),
name: Yup.string().required(),
});
export { userSchema };
//src/modules/validate-user/index.ts
import * as Yup from "yup";
import { userSchema } from "./_internal/user-schema";
function isValidUser(data: unknown): data is Yup.InferType<typeof userSchema> {
return userSchema.isValidSync(data);
}
Now ESLint will complain if src/index.ts
tries to do something like:
// src/index.ts
import { userSchema } from "~/modules/validate-user/_internal/user-schema"; // 🔴 ESLint: '~/modules/validate-user/_internal/user-schema' import is restricted from being used by a pattern. imports from "_internal" directory are restricted to prevent importer from depending on implementation details.
const data = { name: "Aria" };
if (userSchema.isValidSync(data)) {
// ...
} else {
// ...
}
These rules are enforced using ESLint. In .eslintrc.json
, inside rules
object, you can find the configuration for no-restricted-imports
rule.
If you want to disable this rule, change it from "error"
to "off"
.
In many cases, environment variables are not included in the repository. Thus when the repository is cloned, people have no idea what the environment variables are & what values are valid. Of course you can create a separate document for explaining environment variables but just like comments, your document can easily get out of sync with the actual code.
In addition, environment variables have to get validated & proper error messages should be displayed.
Oh, one more thing, environment variables are always strings. So if you want to get a number from environment variable (for example a port number
) you have to perform type coercion.
All of this logic can be encapsulated in a tsEnv
object. You can use this object to safely access environment variables. As you can see in the following demo, type coercion also works.
This project uses the same load order as Next.JS.
Environment variables are looked up in the following places, in order, stopping once the variable is found.
process.env
.env.$(NODE_ENV).local
.env.local
(Not checked when NODE_ENV is test.).env.$(NODE_ENV)
.env
For example, if NODE_ENV
is development
and you define a variable in both .env.development.local
and .env
, the value in .env.development.local
will be used.
💡 Note: The allowed values for
NODE_ENV
areproduction
,development
andtest
.
🖊️ You can change the allowed values insrc/modules/load-env/env-schema.ts
The schema for environment variables are defined using Zod in src/modules/load-env/env-schema.ts
.
If you take a closer look at EXPRESS_PORT
, you'll notice that initially it is defined as string. But if all of the following conditions hold, at the end it will get transformed to a number.
- it can be parsed using
parseFloat
function - the parsed value is a number
- the number is an integer
- it is not smaller than 0
- it is not greater than 65535
Once you import tsEnv
from src/modules/load-env/env-schema.ts
file:
.env
files will be loaded according toNODE_ENV
value. (Under the hood, dotenv is used to load.env
files.)- Variables will be validated & transformed based on the schema.
- If no errors occur, you can access variables from
tsEnv
object. - In case of errors, an exception is thrown.
This template doesn't contain any package.lock
,yarn.lock
or pnpm-lock.yaml
file.
You can run npm install
, yarn install
or pnpm install
after cloning the template without getting any conflicts or warnings.
❗ You should always commit the lock file of your package manager.
Sometimes you might want to share editor settings & extensions between project collaborators. Or you might want to use some settings only for a specific project. Most code editors have some solution for this scenario.
For VS Code, Project specific settings are stored in .vscode/settings.json
& recomended extensions are stored in .vscode/extensions.json
.
For example in this project, editor.formatOnSave
is enabled.
"Conventional Commits" is specification for adding human and machine readable meaning to commit messages. Sticking to a commit message specification can make searching & browsing commits easier. In addition, you can use other tools like Standard Version to automate changelog creation & versioning. (Standard Version is not included with this template).
This template uses commitizen so you create "Conventional Commit Messages".
Run npx cz
to generate a "Conventional Commit".
You can modify each step from scripts/build/build-steps.ts
file. You can also add or remove build steps. The build steps in the array are executed from top to bottom. If an error occurs in a build step, build process will be cancelled and further steps won't get executed.
TypeScript files are under ./scripts
directory are transpiled using Babel. Babel doesn't perform type checking. It simply removes type definitions from TypeScript source code.
This step makes sure that there are no type errors in ./scripts
before running them.
ESLint checks for errors in src
directory. If at least one error is found, eslint
will fail and prevent further steps from getting executed.
Any number of warnings is fine. However you can configure this behavior using the --max-warnings
flag.
You can learn more about customization options from ESLint CLI Docs
💡 Under the hood, this step executes
lint
script frompackage.json
.
There shouldn't be a failing test when you build for production. This step runs all your tests using Jest. If at least one test is fails, jest
will fail and prevent further steps from getting executed.
💡 Under the hood, this step executes
test
script frompackage.json
.
There shouldn't be a type error test when you build for production. This step checks the types under src
directory using TypeScript Compiler. If at least one error is found, tsc
will fail and prevent further steps from getting executed. If no errors are found, type definitions .d.ts
files will be generated.
💡 Under the hood, this step uses
tsconfig.prod.json
instead oftsconfig.json
for TypeScript configuration. This is necessary in order to prevent generating type definitions for test files.
Source code will be transpiled using babel according to configuration in babel.config.cjs
file.
⚠️ CommonJS support is removed from this template. Transpiler will only generate EcmaScript modules.
This step uses UglifyJS to minify the contents of dist
directory.
You can modify the options for UglifyJS in scripts/build/commands/minify.js
file.
I have used Best-README-Template as a starting point for this file.