A minimal TypeScript project that prints a greeting, accepts a name from the command line, includes a test suite using Vitest, and uses a Makefile to drive common tasks. This tutorial covers project setup, TypeScript configuration, argument parsing, unit testing, and build automation.
Prerequisites
- Node.js 20 or later
- npm 9 or later
- A Unix-like terminal (macOS, Linux, or WSL on Windows)
makeinstalled (pre-installed on macOS and most Linux distributions)
Step 1: Set up the project structure
Create the file
mkdir -p hello-world
touch hello-world/.gitignore
Add the code: hello-world/.gitignore
node_modules/
dist/
.DS_Store
*.log
tmp/
Detailed breakdown
node_modules/excludes installed dependencies, which are restored withnpm install.dist/excludes compiled TypeScript output generated by the build step..DS_Store,*.log, andtmp/cover common OS and temporary artifacts.
Step 2: Initialize the project and install dependencies
Create the file
cd hello-world
npm init -y
Install TypeScript and the test framework as development dependencies:
npm install -D typescript @types/node vitest
Detailed breakdown
npm init -ycreates apackage.jsonwith default values. The-yflag skips the interactive prompts.typescriptprovides thetsccompiler for compiling.tsfiles to JavaScript.@types/nodeprovides TypeScript type definitions for Node.js built-ins likeprocess, which are not included by default.vitestis a fast test runner with built-in TypeScript support and no extra configuration needed.- All dependencies are dev-only since this is a CLI tool that compiles to plain JavaScript.
Step 3: Configure TypeScript
Create the file
touch tsconfig.json
Add the code: tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"declaration": true,
"sourceMap": true,
"skipLibCheck": true
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}
Detailed breakdown
target: "ES2022"compiles to modern JavaScript, keeping features like optional chaining and nullish coalescing.module: "Node16"andmoduleResolution: "Node16"use the Node.js module resolution algorithm, matching how Node resolves imports at runtime.outDir: "dist"places compiled JavaScript in thedist/directory, keeping the source tree clean.rootDir: "src"tells the compiler that all source files live undersrc/, sodist/mirrors thesrc/folder structure.strict: trueenables all strict type-checking options, catching common mistakes at compile time.declaration: truegenerates.d.tstype definition files alongside the compiled JavaScript.sourceMap: truegenerates.js.mapfiles that link compiled JavaScript back to the original TypeScript for debugging.skipLibCheck: trueskips type checking of declaration files innode_modules. This avoids build failures caused by type conflicts between third-party packages (such as Vitest’s internal declarations referencing APIs not available in the configured target).exclude: ["src/**/*.test.ts"]prevents test files from being compiled intodist/. Without this,tscwould outputdist/greeter.test.js, which Vitest would pick up and fail on because the compiled CommonJS output cannot import Vitest’s ESM-only entry point.
Step 4: Create the greeter module
This module contains the core greeting logic, separated from the entry point so it can be tested independently.
Create the file
mkdir -p src
touch src/greeter.ts
Add the code: src/greeter.ts
/**
* Return a greeting string for the given name.
* Defaults to "World" when no name is provided.
*/
export function greet(name: string = "World"): string {
return `Hello, ${name}!`;
}
Detailed breakdown
greetis exported so it can be imported by both the entry point and test files.- The
nameparameter defaults to"World"when no argument is provided. - The function returns a string rather than printing directly. This makes it easy to test the return value without capturing stdout.
- Template literals build the greeting with clean string interpolation.
Step 5: Create the command-line entry point
The entry point reads an optional --name argument from the command line and prints the greeting.
Create the file
touch src/main.ts
Add the code: src/main.ts
import { greet } from "./greeter.js";
function parseArgs(args: string[]): string {
const nameIndex = args.indexOf("--name");
if (nameIndex !== -1 && nameIndex + 1 < args.length) {
return args[nameIndex + 1];
}
return "";
}
const name = parseArgs(process.argv.slice(2));
console.log(greet(name || undefined));
Detailed breakdown
- The import uses
"./greeter.js"with the.jsextension because Node16 module resolution requires explicit extensions. TypeScript resolves this togreeter.tsat compile time. parseArgsscansprocess.argvfor a--nameflag followed by a value. This avoids adding a CLI parsing dependency for a single flag.process.argv.slice(2)skips the first two elements (nodepath and script path), leaving only user-supplied arguments.greet(name || undefined)passesundefinedwhen no name is found, letting the default parameter ingreetapply.
Step 6: Create the test suite
Tests verify the default greeting, a custom name, and the empty-string edge case.
Create the file
touch src/greeter.test.ts
Add the code: src/greeter.test.ts
import { describe, it, expect } from "vitest";
import { greet } from "./greeter.js";
describe("greet", () => {
it("returns default greeting when no name is provided", () => {
expect(greet()).toBe("Hello, World!");
});
it("returns greeting with custom name", () => {
expect(greet("TypeScript")).toBe("Hello, TypeScript!");
});
it("returns greeting with empty string", () => {
expect(greet("")).toBe("Hello, !");
});
});
Detailed breakdown
describegroups related tests under the"greet"label for organized output.- The first test calls
greet()with no arguments to verify the default"World"parameter. - The second test passes
"TypeScript"and checks the formatted output. - The third test documents the edge case when an empty string is passed explicitly, bypassing the default parameter.
- The import uses
"./greeter.js"to match the Node16 module resolution used in the rest of the project.
Step 7: Add npm scripts
Update package.json to include build, start, and test scripts.
Create the file
The package.json already exists from npm init. Update it to include these scripts:
npm pkg set scripts.build="tsc"
npm pkg set scripts.start="node dist/main.js"
npm pkg set scripts.test="vitest run"
Detailed breakdown
npm pkg setmodifiespackage.jsonfields from the command line without manual editing.scripts.buildrunstscto compile TypeScript source files into thedist/directory.scripts.startruns the compiled entry point with Node.js.scripts.testrunsvitest runwhich executes all test files once and exits (as opposed to watch mode).
Step 8: Create the Makefile
The Makefile provides a single command interface for building, running, testing, and cleaning the project. Running make with no arguments prints a help screen.
Create the file
touch Makefile
Add the code: Makefile
.DEFAULT_GOAL := help
.PHONY: help install build run test clean
help: ## Show this help screen
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf " %-15s %s\n", $$1, $$2}'
install: ## Install dependencies
npm install
build: ## Compile TypeScript to JavaScript
npx tsc
run: build ## Build and run the application (use NAME= to set a custom name)
node dist/main.js $(if $(NAME),--name "$(NAME)",)
test: ## Run the test suite
npx vitest run
clean: ## Remove build artifacts
rm -rf dist/
Detailed breakdown
.DEFAULT_GOAL := helpensures that running plainmakeprints the help screen rather than triggering a build.- The
helptarget usesgrepandawkto extract targets annotated with##comments and format them into a readable list. installrunsnpm installto restore dependencies frompackage.json.buildusesnpx tscto compile TypeScript. Usingnpxensures the locally installedtscis used rather than a global installation.rundepends onbuild, so it always compiles before executing. The optionalNAMEvariable lets users runmake run NAME=TypeScript.testusesnpx vitest runto execute all test files once.cleanremoves thedist/directory. Sincedist/is in.gitignore, this only affects local build artifacts.
Step 9: Run and validate
Execute the following commands from the hello-world/ directory.
Install dependencies
cd hello-world && npm install
Verify the help screen
make
Expected output:
help Show this help screen
install Install dependencies
build Compile TypeScript to JavaScript
run Build and run the application (use NAME= to set a custom name)
test Run the test suite
clean Remove build artifacts
Build and run the application
make run
Expected output:
Hello, World!
Run with a custom name
make run NAME=TypeScript
Expected output:
Hello, TypeScript!
Run the tests
make test
Expected output (summary):
✓ src/greeter.test.ts (3)
✓ greet (3)
✓ returns default greeting when no name is provided
✓ returns greeting with custom name
✓ returns greeting with empty string
Test Files 1 passed (1)
Tests 3 passed (3)
Clean build artifacts
make clean
Troubleshooting
Cannot find module './greeter.js': Make sure imports use the.jsextension. TypeScript with Node16 module resolution requires explicit extensions that map to the compiled output.make: *** No rule to make target ...: Verify the Makefile uses tabs for indentation, not spaces. Many editors convert tabs to spaces by default.tsc: command not found: Usenpx tscinstead of baretsc. The compiler is installed locally innode_modules/.bin/, andnpxresolves it automatically.- Vitest not found: Run
npm installfirst to install dev dependencies, or usemake install.
Recap
This tutorial built a complete TypeScript hello-world project with:
- A greeter module with a typed, exported
greetfunction - A command-line entry point with
--nameargument parsing - A Vitest test suite with three test cases
- A Makefile with a default help screen, install, build, run, test, and clean targets
- TypeScript strict mode configuration with Node16 module resolution
Next improvements could include adding a linter like ESLint, formatting with Prettier, or packaging the project as an npm module with a bin entry.