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)
  • make installed (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 with npm install.
  • dist/ excludes compiled TypeScript output generated by the build step.
  • .DS_Store, *.log, and tmp/ 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 -y creates a package.json with default values. The -y flag skips the interactive prompts.
  • typescript provides the tsc compiler for compiling .ts files to JavaScript.
  • @types/node provides TypeScript type definitions for Node.js built-ins like process, which are not included by default.
  • vitest is 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" and moduleResolution: "Node16" use the Node.js module resolution algorithm, matching how Node resolves imports at runtime.
  • outDir: "dist" places compiled JavaScript in the dist/ directory, keeping the source tree clean.
  • rootDir: "src" tells the compiler that all source files live under src/, so dist/ mirrors the src/ folder structure.
  • strict: true enables all strict type-checking options, catching common mistakes at compile time.
  • declaration: true generates .d.ts type definition files alongside the compiled JavaScript.
  • sourceMap: true generates .js.map files that link compiled JavaScript back to the original TypeScript for debugging.
  • skipLibCheck: true skips type checking of declaration files in node_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 into dist/. Without this, tsc would output dist/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

  • greet is exported so it can be imported by both the entry point and test files.
  • The name parameter 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 .js extension because Node16 module resolution requires explicit extensions. TypeScript resolves this to greeter.ts at compile time.
  • parseArgs scans process.argv for a --name flag followed by a value. This avoids adding a CLI parsing dependency for a single flag.
  • process.argv.slice(2) skips the first two elements (node path and script path), leaving only user-supplied arguments.
  • greet(name || undefined) passes undefined when no name is found, letting the default parameter in greet apply.

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

  • describe groups 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 set modifies package.json fields from the command line without manual editing.
  • scripts.build runs tsc to compile TypeScript source files into the dist/ directory.
  • scripts.start runs the compiled entry point with Node.js.
  • scripts.test runs vitest run which 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 := help ensures that running plain make prints the help screen rather than triggering a build.
  • The help target uses grep and awk to extract targets annotated with ## comments and format them into a readable list.
  • install runs npm install to restore dependencies from package.json.
  • build uses npx tsc to compile TypeScript. Using npx ensures the locally installed tsc is used rather than a global installation.
  • run depends on build, so it always compiles before executing. The optional NAME variable lets users run make run NAME=TypeScript.
  • test uses npx vitest run to execute all test files once.
  • clean removes the dist/ directory. Since dist/ 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 .js extension. 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: Use npx tsc instead of bare tsc. The compiler is installed locally in node_modules/.bin/, and npx resolves it automatically.
  • Vitest not found: Run npm install first to install dev dependencies, or use make install.

Recap

This tutorial built a complete TypeScript hello-world project with:

  1. A greeter module with a typed, exported greet function
  2. A command-line entry point with --name argument parsing
  3. A Vitest test suite with three test cases
  4. A Makefile with a default help screen, install, build, run, test, and clean targets
  5. 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.