Skip to content

CertainlyAria/typescript-starter

Repository files navigation

Contributors Forks Stargazers Issues MIT License


Documented Typescript Starter Kit

Documented TypeScript Template to Speed Up New Project Setup
CodeSandbox.io Β Β Β·Β Β  Report a Bug or Request a Feature

Features

  1. 🧩 Some Useful Commands
  2. β˜‘οΈ Strict TypeScript Configuration
  3. βœ… Static Code Analysis with ESLint
  4. 🎨 Consistent Formatting with Prettier
  5. πŸ“œ Import Order Rules
  6. πŸ‘¨β€πŸ’» Automatic Process Restart During Development With Nodemon
  7. ✨ Use New JavaScript Features In Older Node Versions
  8. ⛓️ Root Imports
  9. πŸ§ͺ Jest For Running Tests
  10. πŸ’‚ Import Guard for "_internal" directory
  11. 🌐 .env files & Type Safe Environment Variables
  12. πŸ“¦ Compatible with npm, yarn & pnpm
  13. βš™οΈ Recommended VS-Code Settings & Extensions
  14. πŸ“ Consistent Commit Messages with Commitizen
  15. 🧱 A Simple Build Pipeline
    1. Perform Type Checking on the Contents of /scripts Directory
    2. Check for Linting Problems
    3. Check for Failed Tests
    4. Generate Types (.d.ts files) for the /src Directory
    5. Transpile using babel
    6. Minify

How To Use

Using GitHub Templates

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.

Fresh Start

git clone https://github.com/CertainlyAria/typescript-starter.git new-project

cd new-project

rm -rf .git

git init

Keeping the Commit History

git clone https://github.com/CertainlyAria/typescript-starter.git new-project

cd new-project

git remote remove origin

(back to top)

Compatibility Note

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

🧩 Some Useful Commands

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.

dev

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.

build

Creates a production build. See A Simple Build Pipeline for more info.

test:watch

Runs your tests in watch mode. When you change a file, the tests will run again.

See Jest For Running Tests for more info.

test

Runs all of your tests once.

See Jest For Running Tests for more info.

prettier:check

Checks formatting of all files using Prettier. See Consistent Formatting with Prettier for more info.

prettier:format

Formats all files using Prettier. See Consistent Formatting with Prettier for more info.

eslint:check

Checks linting issues in all files using ESLint. See Static Code Analysis with ESLint for more info.

tsc:check

Checks for type errors in all files.

Feature Docs

β˜‘οΈ Strict TypeScript Configuration

Why?

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.

How?

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.

(back to top)

βœ… Static Code Analysis with ESLint

Why?

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

How?

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;
}

(back to top)

🎨 Consistent Formatting with Prettier

Why?

You should not worry about code style & formatting. Tools such as prettier already handle that.

How?

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

(back to top)

πŸ“œ Import Order Rules

Why?

As the project size grows, so does the number of imports. Grouping import statements together can make distinguishing various import types easier.

Example:

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";

How?

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.

(back to top)

πŸ‘¨β€πŸ’» Automatic Process Restart During Development With Nodemon

Why?

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.

How?

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.

(back to top)

✨ Use New JavaScript Features In Older Node Versions

Why?

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.

How?

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"
        ]
    },

(back to top)

⛓️ Root Imports

Why?

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';

How?

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 from tsconfig.json should be synchronozied with root & alias in babel.config.js

(back to top)

πŸ§ͺ Jest For Running Tests

Why?

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.

How?

Jest works out of the box with babel. You can use the jest.config.ts file in this project to further customize Jest.

(back to top)

πŸ’‚ Import Guard for _internal directory

Why?

I use this pattern to prevent depending on implementation details.

Example

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 {
    // ...
}

How?

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".

(back to top)

🌐 .env files & Type Safe Environment Variables

Why?

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.

ts-env

Load Order of Environment Variables

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.

  1. process.env
  2. .env.$(NODE_ENV).local
  3. .env.local (Not checked when NODE_ENV is test.)
  4. .env.$(NODE_ENV)
  5. .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 are production, development and test.

πŸ–ŠοΈ You can change the allowed values in src/modules/load-env/env-schema.ts

How?

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.

  1. it can be parsed using parseFloat function
  2. the parsed value is a number
  3. the number is an integer
  4. it is not smaller than 0
  5. it is not greater than 65535

Once you import tsEnv from src/modules/load-env/env-schema.ts file:

  1. .env files will be loaded according to NODE_ENV value. (Under the hood, dotenv is used to load .env files.)
  2. Variables will be validated & transformed based on the schema.
  3. If no errors occur, you can access variables from tsEnv object.
  4. In case of errors, an exception is thrown.

(back to top)

πŸ“¦ Compatible with npm, yarn & pnpm

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.

(back to top)

βš™οΈ Recommended VS-Code Settings & Extensions

Why?

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.

How?

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.

(back to top)

πŸ“ Consistent Commit Messages with Commitizen

Why?

"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).

How?

This template uses commitizen so you create "Conventional Commit Messages".

Run npx cz to generate a "Conventional Commit".

🧱 A Simple Build Pipeline

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.

Perform Type Checking on the Contents of /scripts Directory

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.

Check for Linting Problems

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 from package.json.

Check for Failed Tests

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 from package.json.

Generate Types (.d.ts files) for the /src Directory

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 of tsconfig.json for TypeScript configuration. This is necessary in order to prevent generating type definitions for test files.

Transpile Using Babel

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.

Minify the Output

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.

(back to top)

Other

ReadMe Template

I have used Best-README-Template as a starting point for this file.

(back to top)