Welcome to this 60-minute TypeScript tutorial. This guide is designed for experienced developers (familiar with Python, PHP, JavaScript, and general web development) who are new to TypeScript, Node.js/npm, React, and related tools. We’ll build a simple full-stack project (Node.js API + React frontend) using TypeScript, all running in Docker containers with Docker Compose for easy setup.
Each section below includes hands-on examples with small code snippets. You can run these examples in a Docker environment (using Dockerfiles and Docker Compose configurations provided). Short, step-by-step instructions and code comments will help you follow along. Let’s dive in!
What is TypeScript and why use it?
TypeScript is a typed superset of JavaScript that compiles to plain JavaScript (TypeScript in Visual Studio Code). In other words, it’s just JavaScript with optional static typing and some new language features. All valid JavaScript code is also valid TypeScript, but TypeScript adds syntax for type annotations, interfaces, enums, generics, and more (TypeScript vs. JavaScript: Explaining the differences | Contentful). The primary benefit is catching errors during development instead of at runtime (TypeScript vs. JavaScript: Explaining the differences | Contentful) (TypeScript vs. JavaScript: Explaining the differences | Contentful). With TypeScript you can enforce data types, better communicate code intent, and enjoy enhanced IDE support and tooling (TypeScript vs. JavaScript: Explaining the differences | Contentful). These features help you write more robust and maintainable code, especially in large projects.
Key differences from JavaScript: Unlike vanilla JS, TypeScript code must be compiled (transpiled) to JS before it runs. This compilation (usually done with the tsc TypeScript compiler) will check types and report errors if, for example, you pass a string where a number is expected. JavaScript is dynamically typed (type errors surface only when the code is executed), whereas TypeScript is statically typed (type errors are caught at compile time) (TypeScript vs. JavaScript: Explaining the differences | Contentful) (TypeScript vs. JavaScript: Explaining the differences | Contentful). TypeScript also introduces language features not in standard JS, such as interfaces, generics, and access modifiers, to facilitate large-scale application development (TypeScript vs. JavaScript: Explaining the differences | Contentful).
Example – Static typing vs dynamic typing: In JavaScript you might accidentally do this without immediate error:
// JavaScript example (dynamic typing)
let age = 25;
age = "twenty five"; // allowed in JS, but this will cause problems at runtime if used as a number
console.log(age * 2); // NaN at runtime (no compile error)In TypeScript, you can catch such issues early by declaring types:
// TypeScript example (static typing)
let age: number = 25;
age = "twenty five"; // compile-time error: Type 'string' is not assignable to type 'number'(TypeScript vs. JavaScript: Explaining the differences | Contentful)The TypeScript compiler (and your editor) will immediately flag the line above as an error. For instance, in VS Code you’d see an error underline with a message like “Type 'string' is not assignable to type 'number'.” This prevents runtime issues. Below, you can see how VS Code highlights this error: (TypeScript vs. JavaScript: Explaining the differences | Contentful)
In the image, TypeScript catches the type mismatch (string vs number) during development, which would save you from a potential bug.
Setting up a TypeScript project with npm: Let’s create a basic TypeScript project. You’ll need Node.js and npm (Node’s package manager) installed. If you’re using Docker for everything, we will set that up shortly – but it’s still useful to understand the project structure.
Initialize npm: Create a new folder for your project (e.g., typescript-demo) and open a terminal there. Run npm init -y. This creates a package.json with default values. (The -y flag skips the questionnaire and uses defaults.)
Install TypeScript compiler: Run npm install --save-dev typescript. This installs TypeScript as a development dependency (since it’s needed to compile your code, but not at runtime). You can install it globally with npm install -g typescript, but using a local dev dependency is common in projects.
Initialize TypeScript configuration: Run npx tsc --init. The tsc command is the TypeScript compiler. Using npx (which comes with npm 5.2+ (Difference between npx and npm? - javascript - Stack Overflow)) executes the local tsc binary without globally installing it. This command creates a default tsconfig.json file in your project.
Configuring tsconfig.json: The tsconfig.json file tells the TypeScript compiler how to compile your project. It marks the root of your TypeScript project and specifies important options (TypeScript: Documentation - What is a tsconfig.json). Open tsconfig.json in an editor – you’ll see a lot of commented options. For now, ensure it has at least these options in "compilerOptions":
{
"compilerOptions": {
"target": "ES6", // which JS version to compile to (ES6 = ECMAScript 2015)
"module": "commonjs", // module system (commonjs for Node.js, or "ESNext" for ESM)
"outDir": "dist", // output directory for compiled JS files
"rootDir": "src", // root directory of your TS source files
"strict": true, // enable all strict type-checking options
"esModuleInterop": true // allow compatibility imports (for CommonJS default exports)
},
"include": ["src"]
}These settings say: “Take all .ts files in the src folder, and compile them to JavaScript in a dist folder.” You can tweak many options in tsconfig.json to suit your needs; the above is a reasonable starting point. (For example, "strict": true enables TypeScript’s strict type-checking mode which is recommended for new projects.)
Project structure: Create a src directory in your project. All your TypeScript code will go here. For our demo, create a simple file src/index.ts:
// src/index.ts
function greet(name: string): string {
return `Hello, ${name}!`;
}
const user = "World";
console.log( greet(user) );This defines a greet function that expects a string and returns a string, then uses it. Notice the type annotation : string for the function parameter and return type.
Compile and run: To compile the project, run npx tsc. This will read tsconfig.json and compile src/index.ts to dist/index.js. You should now have a dist folder with an index.js file. Run it with Node: node dist/index.js. It should print “Hello, World!” in the console. Congratulations – you’ve written and executed your first TypeScript code!
(If you prefer not to compile to a file for every run, you can use npx ts-node src/index.ts to execute TypeScript directly. ts-node is a tool that combines compiling + running in one step. We’ll talk more about such tools later.)
Now that we covered the basics of TypeScript, let’s apply it to a Node.js environment by building a simple backend service. We’ll also introduce Docker here to containerize the Node app.
For organization, create a new directory (in the root of your tutorial folder) for the backend code, e.g., backend/. Move your typescript-demo files into this backend folder (or create a new Node project inside backend similarly with npm init -y and tsc --init). The final structure might look like:
backend/
package.json
package-lock.json
tsconfig.json
src/
index.ts
util.ts (optional, for example module)Inside backend/package.json, add a script to build and run the app. For example:
{
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
},
"devDependencies": {
"typescript": "4.x"
}
}(The exact version of TypeScript may differ. Also, we will add more dev dependencies later for hot-reload, but we’ll get to that.)
We’ll write a slightly more complex example to demonstrate TypeScript features like modules, interfaces, and generics on the backend.
Example backend code (TypeScript):
Create backend/src/util.ts:
// backend/src/util.ts
export interface User {
id: number;
name: string;
}
// A generic function example (returns the argument as is, with type preserved)
export function identity<T>(value: T): T {
return value;
}
// A simple add function
export function add(a: number, b: number): number {
return a + b;
}And modify backend/src/index.ts:
// backend/src/index.ts
import { User, identity, add } from "./util";
// Use the imported functions and interface:
const sampleUser: User = { id: 1, name: "Alice" };
console.log(`User: ${sampleUser.name} (id: ${sampleUser.id})`);
// Using the generic identity function
const output = identity<string>("TypeScript is great!");
console.log(output);
// Using the add function
console.log(`2 + 3 = ${add(2, 3)}`);
// Intentionally cause a type error (uncomment the next line to see TypeScript catch it):
// sampleUser.id = "not a number"; // Error: Type 'string' is not assignable to type 'number'In this code:
- We defined an interface User with two fields. In index.ts, we create an object sampleUser of type User. If we try to add a field that isn’t in the interface or use a wrong type, TypeScript will error.
- We defined a generic function identity<T> that simply returns whatever is passed in. We explicitly call it with <string> to indicate we expect a string, although TypeScript can infer it. Generics allow writing functions that work with any type while still enforcing type safety.
- We have a simple add function to demonstrate importing and using a module.
- There’s a commented-out line showing a type error example (sampleUser.id = "not a number"; would be caught by the compiler).
Go ahead and run npm run build (which runs tsc) in the backend directory. Fix any mistakes if the compiler complains. Then run npm start to execute the compiled code. You should see console output similar to:
User: Alice (id: 1)
TypeScript is great!
2 + 3 = 5Try toggling the commented error line and recompiling to see TypeScript in action.
Now, let’s containerize this backend using Docker. We’ll write a Dockerfile to define how to build a Docker image for our Node.js TypeScript app. A Dockerfile is essentially a script with instructions to assemble a container image (including installing dependencies, compiling the app, etc.) (How To Dockerize a Typescript Node.js App. | by Rainer Regan | Medium).
Create a file backend/Dockerfile with the following content:
# Use an official Node.js runtime as the base image
FROM node:18-alpine
# Set working directory inside the container
WORKDIR /app
# Copy package.json and package-lock.json first (for dependency installation)
COPY package*.json ./
# Install dependencies (this will install TypeScript and any other deps)
RUN npm install
# Copy the rest of the application source code
COPY . .
# Build the TypeScript code
RUN npm run build
# Expose a port (if our app was a server, e.g., Express on port 4000, we would expose 4000)
# EXPOSE 4000
# Command to run the app (using the compiled JavaScript)
CMD ["npm", "run", "start"]Let’s break down what this Dockerfile does:
/app. All following commands will run in this directory.package.json and package-lock.json into the container. We do this separately to leverage Docker’s layer caching – if these files haven’t changed, Docker can cache the npm install layer (speeds up rebuilds).dist folder.node dist/index.js as defined).Now you can build and run the Docker image: ```bash
docker build -t ts-backend:dev .
docker run --rm ts-backend:dev
``
The--rm` flag just removes the container after it exits (since our current script just runs and exits after printing messages). You should see the same output as before, but now running inside a Docker container.
Note: If you had a long-running server (like an Express API), you would run the container in detached mode
-dand probably map a port with-p 4000:4000so you can access it. We’ll cover that soon when we introduce a web server.
At this point, we have a Node.js project written in TypeScript, and we know how to compile and run it both locally and in Docker. Next, we’ll create a React frontend with TypeScript.
Now let’s set up a frontend using React and TypeScript. The popular way to bootstrap a React project is using Create React App (CRA), which has a template for TypeScript.
We will create a new React app inside a directory (e.g., frontend/). You can use npx to do this easily:
# From the root of your project (where docker-compose.yml will eventually reside):
npx create-react-app frontend --template typescriptThis uses CRA to create a React project named “frontend” and configures it for TypeScript (Getting Started | Create React App). It will generate a new frontend/ folder with a typical React structure and a bunch of files. Key points about this generated project:
tsconfig.json, and the files in src/ use .tsx extension (JSX in TypeScript).npm start in the frontend directory will start a dev server on http://localhost:3000.src/App.tsx and some other files. We’ll modify these soon.Before we dive into code, let’s Dockerize the React app as well, so we can run it in a container.
Dockerizing the React app: Create a frontend/Dockerfile:
FROM node:18-alpine
WORKDIR /app
# Copy react app package files and install dependencies
COPY package*.json ./
RUN npm install
# Copy all source code
COPY . .
# Build the React app for production
RUN npm run build
# Install a simple HTTP server to serve the static files
RUN npm install -g serve
# Expose port 3000 and define the command to run the app
EXPOSE 3000
CMD ["serve", "-s", "build", "-l", "3000"]This Dockerfile:
- Installs dependencies (which include react-scripts, etc.).
- Runs npm run build to create a production build (output in the build/ directory).
- Installs the serve package globally to serve the static files.
- When the container runs, it serves the React app on port 3000.
You can build and run this image similarly:
bash
docker build -t ts-frontend:dev .
docker run -p 3000:3000 --rm ts-frontend:dev
After building, access http://localhost:3000 in your browser – you should see the default CRA “spinning logo” app.
However, for development purposes, we usually don’t build the static files each time; we run the dev server. We will configure this in Docker Compose soon, allowing hot-reload. But first, let’s write some TypeScript code in our React app.
Open the CRA project in your editor (the frontend/ folder). The main file is src/App.tsx. By default it’s a functional component with some placeholder content. We’ll modify this to demonstrate using props and state with TypeScript.
In React (with TypeScript), you typically define an interface or type for your component’s props, and then use that with a functional component. Here’s a simple example of a new component that takes props:
// frontend/src/Hello.tsx
import React from 'react';
// Define an interface for the props
interface HelloProps {
name: string;
enthusiasmLevel?: number; // "?" means this prop is optional
}
// A React Functional Component written in TypeScript
const Hello: React.FC<HelloProps> = ({ name, enthusiasmLevel = 1 }) => {
const greeting = `Hello, ${name}${"!".repeat(enthusiasmLevel)}`;
return <h2>{greeting}</h2>;
};
export default Hello;In this Hello component:
- We declare HelloProps with a required name (string) and an optional enthusiasmLevel (number, default will be 1).
- We use React.FC<HelloProps> to type the functional component. This ensures the component can only be used with the correct props.
- Inside the component, we use the props just like normal. If someone tries to use <Hello name={123} /> or forgets the name prop, TypeScript will error.
Now, modify your App.tsx to use this new component:
// frontend/src/App.tsx
import React from 'react';
import Hello from './Hello';
function App() {
return (
<div>
<h1>Welcome to my TypeScript React App</h1>
<Hello name="TypeScript" enthusiasmLevel={3} />
</div>
);
}
export default App;Run the app (npm start or via Docker Compose as we’ll do soon) and you should see “Hello, TypeScript!!!” on the page (3 exclamation marks because enthusiasmLevel 3).
State management in TypeScript React components: State in functional components is usually handled with React hooks like useState and useEffect. TypeScript works with these hooks by inferring types from initial values, or you can explicitly type them. For example, inside App we could add:
// Example of using useState with TypeScript
import React, { useState, useEffect } from 'react';
function App() {
const [counter, setCounter] = useState<number>(0); // state type is number
useEffect(() => {
const timer = setInterval(() => setCounter(c => c + 1), 1000);
return () => clearInterval(timer);
}, []);
return (
<div>
<h1>Counter: {counter}</h1>
<Hello name="TypeScript" enthusiasmLevel={3} />
</div>
);
}Here we used useState<number>(0) – by providing the <number> generic, we ensure counter is a number. (TS could infer it from the initial 0, but this is how you’d specify type if needed.) The counter will increment every second, just as a quick demo of state.
The key takeaway is: TypeScript integrates smoothly with React. You describe prop types with an interface or type, and state and effects just work with type inference. The React + TypeScript template has type definitions (@types/react) installed, so things like React.FC and useState are already typed.
We’ve already created a Dockerfile for the React app to build it for production. If you build that image and run it, you get a container serving the app (good for testing production). However, during development, you’d use the npm start dev server because it auto-reloads on file changes. We can use Docker Compose to facilitate this, which we’ll do in the next section.
For now, ensure that both the backend and frontend projects are working:
- The backend compiles and runs (printing messages).
- The frontend dev server runs (npm start in frontend should open a browser to the app).
Next, we’ll look at some useful npm/npx commands and TypeScript tools, and then combine everything with Docker Compose.
By now, you’ve already used npm (to install packages and run scripts) and npx (to execute create-react-app and tsc). Let’s clarify these and mention some additional tools that make TypeScript development easier.
npm (Node Package Manager): You use npm install to install dependencies from the npm registry. We installed typescript, react, etc., using npm. Dependencies are listed in package.json. For example, adding a library like Express for our backend would be npm install express. If a library doesn’t include its own TypeScript definitions, you can often install the types from DefinitelyTyped, e.g., npm install --save-dev @types/express (these provide type information for the library so TypeScript can type-check your usage of it).
npx: This tool comes with npm (v5.2+). It’s used to execute binaries from your dependencies or from npm without installing globally (npm vs npx — What's the Difference? - freeCodeCamp). We used npx tsc (which ran our locally installed TypeScript compiler) and npx create-react-app (which fetched the package and ran it in one go). Essentially, use npx <command> to run a package’s CLI tool without cluttering global installs. Common uses: npx tsc, npx ts-node, npx eslint --fix, etc.
TypeScript compiler (tsc): We have used tsc to compile TS to JS. Some useful flags/commands:
tsc --watch (or tsc -w): Watch mode. This will keep the compiler running, automatically re-compiling files on save. Useful in development to continuously compile your backend as you edit.tsc --project tsconfig.json: You can run tsc specifying a particular tsconfig (by default it finds one in the current directory).npm run build) to encapsulate these options.ts-node: A utility that lets you run TypeScript files directly (it does the compilation in-memory and executes the JS). This is great for scripts or quick experimentation. Example: npx ts-node src/index.ts would run our backend without producing a dist file. It’s not recommended for production but excellent in development or testing. We’ll actually use a variant of this for hot-reloading.
Nodemon / Hot-reloading: In a Node.js app, when you change source files, you normally have to restart the process to see changes (unlike React’s dev server which reloads automatically). Nodemon is a tool that watches files and restarts your Node process when files change. We can use Nodemon with TypeScript by having it run ts-node under the hood. There’s also ts-node-dev which is similar (it’s like nodemon + ts-node combined). We will use one of these in our Docker Compose setup so that our backend restarts on code changes.
Debugging tips: When debugging TypeScript, one important thing is source maps. Source maps map the transpiled JS back to the original TS. We enabled "sourceMap": true in our tsconfig (if not, add it), which means the compiler outputs .js.map files alongside the .js. These let debuggers (like VS Code or browser dev tools) show you TypeScript code in debugging sessions (TypeScript tutorial in Visual Studio Code). For Node, VS Code can use the source maps to let you set breakpoints in your TS files and step through code. We’ll touch on VS Code debugging soon.
ESLint: While not TypeScript-specific, using a linter is highly recommended. ESLint can be configured to work with TypeScript (via @typescript-eslint parser). Create React App comes with a basic ESLint setup out-of-the-box. In VS Code, installing the ESLint extension will highlight lint issues as you code. (We’ll cover setting up the VS Code extensions next.)
To summarize, here are a few commands and tools you might use in a TypeScript project:
- npm install <pkg> to add libraries.
- npm install --save-dev <pkg> to add dev tools (like typescript, ts-node, nodemon, testing libs, etc.).
- npx tsc --init to initialize a tsconfig.
- npm run build (with a script) to compile.
- npm start or npm run dev to run the app (perhaps via ts-node or after building).
- npx nodemon --watch src --exec "ts-node src/index.ts" to auto-restart a TS Node app on changes (you can put this in a script).
Next, we will set up Docker Compose to orchestrate running both our backend and frontend together, with hot-reloading enabled for both.
Docker Compose allows us to define and run multi-container applications with a single YAML file. We have two services: the Node.js API (backend) and the React app (frontend). We want to run them together easily, and also enable a good development workflow (editing code on our host machine, and seeing changes live in the containers).
Create a docker-compose.yml file at the root of your project (above the backend/ and frontend/ directories):
version: "3.8"
services:
api:
build: ./backend
ports:
- "4000:4000"
volumes:
- ./backend:/app
- /app/node_modules
command: npm run dev
frontend:
build: ./frontend
ports:
- "3000:3000"
volumes:
- ./frontend:/app
- /app/node_modules
command: npm startLet’s unpack this Compose file:
- We define two services: api and frontend.
- Each service has a build context (pointing to the respective directory). This means Docker Compose will use the Dockerfile in that directory to build the image. (The first time, or whenever Dockerfile changes or you run with --build.)
- ports: We map the container ports to our host. The API will listen on 4000 inside the container, mapped to localhost:4000. The React dev server runs on 3000 inside, mapped to localhost:3000.
- volumes: This is important for development. We mount our code into the container. ./backend:/app means “take my local backend folder and mount it at /app inside the container, overwriting whatever was built into the image.” This way, as we edit code locally, it instantly reflects inside the container. We also add a second volume /app/node_modules – this is a trick to prevent the host from overwriting the node_modules in the container. We want to use the container’s installed modules, not our host’s (which for many, especially Windows/macOS, might not even exist or be incompatible).
- command: We override the default command (which was set to build or serve in our Dockerfiles) to instead run the dev mode. For the API, we’ll create an npm run dev script to start the server in watch mode. For the frontend, npm start is the CRA dev server.
This setup allows us to run docker-compose up and get:
- A backend container running our Node API, restarting on file changes.
- A frontend container running the CRA dev server, with hot-reload for React code.
Enabling hot-reload for the Node API: We need to implement npm run dev in our backend. Let’s use ts-node-dev for simplicity. Install it in the backend: run npm install --save-dev ts-node-dev. Then in backend/package.json, add:
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
}ts-node-dev will watch for changes in .ts files and restart automatically. --transpile-only skips type-checking for speed (you’ll still get type errors from normal tsc or your editor, but this makes reload faster).nodemon: "dev": "nodemon --watch src --exec ts-node src/index.ts", which does similar things.Enabling hot-reload for React: CRA’s dev server already supports hot reload out of the box. We’ve mounted the code, so any edits to files in frontend/src will trigger the CRA dev server to recompile and the browser to refresh. One thing: file watching in Docker containers can sometimes be finicky on certain OSes. If you find changes aren’t detected, you can enable polling. For CRA, you can set an env var CHOKIDAR_USEPOLLING=true to force polling. In Compose, that would be:
environment:
- CHOKIDAR_USEPOLLING=true(This can be necessary on Windows/Mac with Docker.) But try without it first.
Now, let’s run the whole stack:
docker-compose up --build--build ensures images are rebuilt (e.g., to install ts-node-dev we just added).Once up, open http://localhost:3000. You should see the React app (with our Hello component, etc.). The React dev server proxies API calls if configured, but we haven’t set that. If you want the React app to talk to the API, you have two main options:
1. In development, you can call the API at http://localhost:4000 (since we exposed it). However, due to CORS (Cross-Origin Resource Sharing), the browser will block requests from 3000 to 4000 unless the API allows it.
2. Use CRA’s proxy feature: add "proxy": "http://localhost:4000", in frontend/package.json. CRA will then proxy unknown requests (like API calls) to the API, avoiding CORS in dev.
For now, let’s handle CORS in the API for simplicity (so we can test it easily). We will address this in the final project.
The Compose setup above runs both services. If you edit a backend .ts file, you should see the backend container logs show a restart (thanks to ts-node-dev). If you edit a React file, the frontend container logs will show a rebuild and your browser will auto-refresh. This gives a tight feedback loop, all within Docker.
You can stop the services with Ctrl+C. To remove containers (and networks) run docker-compose down.
We now have a full development environment in Docker Compose!
Finally, let’s ensure our development environment (VS Code) is optimized for TypeScript, Node, and React.
Recommended VS Code extensions: - TypeScript and JavaScript Language Features: This comes built-in with VS Code (it provides IntelliSense, autocompletion, and error highlighting for JS/TS). Just make sure it’s enabled (it should be by default). - ESLint: If you plan to use a linter (recommended), install the official ESLint extension. It will highlight code style issues and errors as you type. (CRA includes a basic ESLint config; you’ll see red squiggly underlines for things like unused variables.) After installing, you might need to enable it in VS Code settings. (How To Lint and Format Code with ESLint in Visual Studio Code | DigitalOcean)To install, open the Extensions pane in VS Code, search “ESLint”, and click Install. An example of the ESLint extension page in VS Code is shown below. (How To Lint and Format Code with ESLint in Visual Studio Code | DigitalOcean)
The image above shows the ESLint extension in VS Code. After installing, ESLint will underline problems in your code according to your configuration.
rafce snippet to create a React Arrow Function Component quickly).Debugging TypeScript in VS Code:
- Node.js Debugging: If you want to debug the Node API, you have a couple of options. One easy way during development: run the API with the inspector enabled. For example, adjust the Docker compose command for api to: node --inspect=0.0.0.0:9229 dist/index.js (if running compiled code) or in ts-node-dev you can pass --inspect flag. Then, map port 9229 in docker-compose (e.g., - "9229:9229"). In VS Code, use the “Attach to Node.js” debug configuration, which by default looks for a process listening on 9229. This will attach the debugger to the Node process inside the container. You can then set breakpoints in your TypeScript code. Thanks to source maps, VS Code will stop at the correct lines in your TS files (TypeScript tutorial in Visual Studio Code). Ensure "sourceMap": true and "outDir": "dist" are in tsconfig so VS Code can map to your sources.
- Alternatively, you can debug locally by running your Node app on your machine (outside Docker) with VS Code’s debugger. But if you want the full Docker environment, the remote attach method works as described.
- React Debugging: For React, most debugging is done through the browser. You can use React Developer Tools extension in the browser to inspect component state and props. VS Code can debug front-end code via the Edge/Chrome debugger extensions, but that’s an advanced topic. In practice, console logs or the browser dev tools are sufficient for many cases, especially for a simple project.
VS Code and Docker Compose Integration: VS Code has a “Remote - Containers” extension that can even open your project inside a container for a seamless dev environment. This is optional and beyond our scope, but know that it’s possible to develop “in container” for consistency. For now, our approach is editing locally and running in containers via Compose, which is perfectly fine.
Make sure in VS Code you open the root folder of the project (so it sees both backend and frontend). The TypeScript language server will pick up your tsconfig.json files and provide IntelliSense and error checking. When you open a .ts or .tsx file, you should see any type errors underlined in red. You can hover to see the error message. This tight integration helps catch errors even before running the compiler manually.
Throughout this tutorial, we’ve included hands-on examples. Let’s recap and ensure you try them:
Demo 1 (TypeScript basics): In the Introduction, we created a simple src/index.ts and compiled it. Try to modify the code to cause a type error (e.g., call greet(123) where greet expects a string) and run tsc. See how the compiler reports the error. Then fix it and compile again.
Demo 2 (Node.js backend): We built a small Node app with an interface, a generic, etc. Try running it via Docker:
docker-compose up api (this will just start the api service). It should run npm run dev and wait (since ts-node-dev keeps the process alive).backend/src/index.ts (for example, change the message or add a new log). You should see the container detect the change and restart automatically, printing the updated output.Demo 3 (React frontend): Similarly, start just the frontend: docker-compose up frontend. It will run the CRA dev server. Open http://localhost:3000. Now edit frontend/src/App.tsx (for instance, change the welcome message text). When you save, the browser should refresh and show the new text. This is React’s hot reload in action.
Demo 4 (Full-stack integration): Start both: docker-compose up (both services). Open two browser tabs: one for http://localhost:3000 (frontend) and one for http://localhost:4000 (we haven’t implemented a response yet, but we will in the final challenge). If you have implemented something on the backend (e.g., an Express server), hitting the 4000 URL should show it.
These small demos verify that our Docker + TypeScript setup is working for each part. Each concept we discussed (type checking, modules, React props, etc.) was accompanied by sample code – make sure to run those and observe the behavior. Nothing beats seeing a TypeScript error for yourself or a successful hot-reload after saving a file!
Now it’s time to combine everything you’ve learned in a mini project. The goal is to create a simple API endpoint in the Node backend and consume it from the React frontend. We’ll make a minimal example: the API will have one route that returns a greeting message, and the React app will fetch that message and display it.
Step 1: Extend the Backend (Node API)
We’ll use Express (a popular web framework for Node) to set up a quick API. Install Express and its type definitions in the backend:
# in the backend directory
npm install express
npm install --save-dev @types/expressUpdate backend/src/index.ts to create a basic Express server:
// backend/src/index.ts
import express, { Request, Response } from 'express';
import cors from 'cors'; // we'll allow CORS for simplicity
const app = express();
const PORT = process.env.PORT || 4000;
// Use CORS middleware to allow requests from our frontend
app.use(cors());
// Define a Type for our API response (for TypeScript usage)
interface MessageResponse {
message: string;
}
// API route: GET /api/hello
app.get('/api/hello', (req: Request, res: Response<MessageResponse>) => {
res.json({ message: "Hello from TypeScript Node API" });
});
// Start the server
app.listen(PORT, () => {
console.log(`✅ API is running at http://localhost:${PORT} (inside Docker: ${PORT})`);
});Comments explaining the code:
- We import express and also import Request and Response types for typing our route handler.
- We use cors() to allow cross-origin requests (so our React dev server at localhost:3000 can call this API at localhost:4000). In a real app, you’d configure CORS more restrictively.
- We define an interface MessageResponse to type the JSON we’ll return.
- We set up a GET route at /api/hello that responds with a JSON object like { message: "Hello from TypeScript Node API" }. We annotate the res: Response<MessageResponse> to indicate the response body structure.
- Finally, we start the server on port 4000 (the Docker container will use this, and we mapped it to host 4000 in the compose file). We log a message when it’s up (prefixed with ✅ just to stand out).
Update backend/package.json to have the updated scripts if needed and ensure ts-node-dev is installed (we did that earlier). Our npm run dev will now run this server and keep running (no longer an exit-after-print program).
Step 2: Extend the Frontend (React app)
In the React app, we’ll fetch from this API. We can do this in App.tsx directly for this simple case.
Open frontend/src/App.tsx and modify it:
import React, { useState, useEffect } from 'react';
interface MessageResponse {
message: string;
}
function App() {
const [apiMessage, setApiMessage] = useState<string>("(Loading...)");
useEffect(() => {
// Fetch the API message on component mount
fetch('http://localhost:4000/api/hello')
.then(res => res.json())
.then((data: MessageResponse) => {
setApiMessage(data.message);
})
.catch(err => {
console.error("Error fetching message:", err);
setApiMessage("(Failed to load)");
});
}, []); // empty dependency array -> runs once on mount
return (
<div style={{ fontFamily: 'sans-serif', textAlign: 'center', marginTop: '2rem' }}>
<h1>My TypeScript Full-Stack App</h1>
<p><strong>Message from API:</strong> {apiMessage}</p>
</div>
);
}
export default App;Let’s explain:
- We define a MessageResponse interface to match the API response shape (so TypeScript knows data.message is a string).
- We use React’s state hook to keep apiMessage. It starts as "(Loading...)".
- In a useEffect with empty deps (so it runs only on initial render), we call fetch('http://localhost:4000/api/hello'). When the promise resolves, we parse JSON and then cast the data to MessageResponse type (or you could use data as MessageResponse). We then set the message in state.
- If there’s an error (e.g., network issue), we catch it and set a failure message.
- The UI just displays a heading and the message from the API (or the loading/failure text).
Step 3: Update Docker Compose (if needed)
Our docker-compose.yml already exposes port 4000 for the API and 3000 for the frontend. We added Express and maybe other dependencies, so we should rebuild:
bash
docker-compose up --build
This will rebuild the backend image (installing express) and start both services.
Step 4: Test the integrated app
- Go to http://localhost:3000 in your browser. You should see “My TypeScript Full-Stack App” and below that, “Message from API: Hello from TypeScript Node API”. The message came from the backend!
- You can also directly visit http://localhost:4000/api/hello in the browser; you should see the raw JSON: {"message":"Hello from TypeScript Node API"}.
- Try editing the backend message (change the string in res.json({...}) to something else), and watch the Docker logs: the backend should restart (ts-node-dev) and the frontend might show the old message until you refresh. Refresh the browser to see the new message from the API.
- You can also edit the frontend (e.g., change the text in the <h1> tag). The browser should live-update (CRA will refresh the page or inject changes).
In this chapter, we will learn how to create an executable npx app that runs TypeScript files directly. This allows us to distribute a command-line tool that users can run using npx <your-app>, without needing to install it globally.
To create an npx-runnable app, we need to set up an npm package.
mkdir my-npx-app && cd my-npx-app
npm init -yThis creates a package.json file with default settings.
Inside your project, create a bin/ directory and an index.ts file:
mkdir bin
touch bin/index.tsNow, open bin/index.ts and add the following code:
#!/usr/bin/env ts-node
console.log("Hello from My NPX App!");#!/usr/bin/env ts-node) tells the OS to run the script using ts-node (which allows running TypeScript without prior compilation).npx my-npx-app.package.jsonModify your package.json to include a bin field, which defines the command users will run:
{
"name": "my-npx-app",
"version": "1.0.0",
"bin": {
"my-npx-app": "bin/index.ts"
},
"dependencies": {},
"devDependencies": {
"typescript": "^4.0.0",
"ts-node": "^10.0.0"
}
}"bin" field registers my-npx-app as an executable command."devDependencies" install typescript and ts-node (needed for execution).Now, install the required dependencies:
npm installTo allow the system to run this script as a command, set its execution permissions:
chmod +x bin/index.tsBefore publishing, test your executable without installing it globally.
Run the command:
bash
npx ./bin/index.ts
You should see:
Hello from My NPX App!
Alternatively, you can test it as a global package:
bash
npm link # Creates a global symlink
my-npx-app # Runs the command
This makes my-npx-app available globally on your machine.
If you want others to use your npx app, publish it to npm:
npm publish --access publicOnce published, anyone can run your tool with:
bash
npx my-npx-app
You’ve now created an executable TypeScript-based NPX app! This method allows you to:
- Distribute CLI tools easily via npx.
- Execute TypeScript files directly without compiling them first.
- Run commands without global installation (perfect for one-time utilities).
This is how many CLI tools (like create-react-app, ts-node, and eslint) are built. Now, you can create and distribute your own npx-powered TypeScript applications! 🚀
This TypeScript cheat sheet summarizes the key syntax, types, and features you need to know, with concise code snippets and explanations. Use this as a quick reference while coding in TypeScript.
TypeScript provides strict type checking for common JavaScript types.
let num: number = 42;
let str: string = "Hello";
let isDone: boolean = true;
let arr: number[] = [1, 2, 3]; // Array of numbers
let mixedArr: (number | string)[] = [1, "two", 3]; // Array with mixed typesnumber, string, boolean, and arrays (T[] or Array<T>) for type annotations.|) to allow multiple types.TypeScript can automatically infer types.
let message = "Hello, TypeScript"; // Inferred as string
// message = 10; // Error: Type 'number' is not assignable to type 'string'TypeScript allows function type annotations.
function add(a: number, b: number): number {
return a + b;
}
const greet = (name: string): string => `Hello, ${name}`;=>) like in JavaScript.Use ? for optional parameters, and = for default values.
function greet(name: string, age?: number): string {
return age ? `${name} is ${age} years old` : `Hello, ${name}`;
}
function multiply(a: number, b: number = 10): number {
return a * b;
}
console.log(greet("Alice")); // Hello, Alice
console.log(greet("Bob", 25)); // Bob is 25 years old
console.log(multiply(5)); // 50 (default b = 10)Interfaces define object structure and enforce type safety.
interface Person {
name: string;
age: number;
isStudent?: boolean; // Optional property
}
const user: Person = { name: "Alice", age: 25 };? for optional properties.Type aliases work similarly to interfaces but are more flexible.
type Point = { x: number; y: number };
const point: Point = { x: 10, y: 20 };type for aliases and complex types.Prevent modification using readonly or const.
interface Car {
readonly brand: string;
model: string;
}
const myCar: Car = { brand: "Toyota", model: "Camry" };
// myCar.brand = "Honda"; // Error: brand is readonly
const numbers: readonly number[] = [1, 2, 3];
// numbers.push(4); // Error: Readonly arrayEnums define a set of named constants.
enum Status {
Success = 200,
NotFound = 404,
Error = 500,
}
const response: Status = Status.Success;
console.log(response); // 200Tuples define fixed-length arrays with specific types.
let tuple: [string, number] = ["Alice", 30];
// tuple = [30, "Alice"]; // Error: Order must match|) allows multiple possible types.&) combines multiple types.// Union type
let id: number | string;
id = 123;
id = "ABC123";
// Intersection type
type Employee = { name: string };
type Manager = { department: string };
type Boss = Employee & Manager;
const boss: Boss = { name: "Alice", department: "HR" };as and <type>)Force TypeScript to treat a value as a specific type.
let someValue: unknown = "Hello TypeScript";
let strLength: number = (someValue as string).length;
// OR
let strLength2: number = (<string>someValue).length;Generics allow functions to work with multiple types.
function identity<T>(value: T): T {
return value;
}
console.log(identity<string>("Hello")); // Hello
console.log(identity<number>(42)); // 42<T> represents a generic type that works with any data type.TypeScript supports OOP concepts like classes, interfaces, and inheritance.
class Animal {
constructor(public name: string) {}
makeSound(): void {
console.log("Animal makes a sound");
}
}
class Dog extends Animal {
bark(): void {
console.log("Woof! Woof!");
}
}
const myDog = new Dog("Buddy");
myDog.makeSound(); // Animal makes a sound
myDog.bark(); // Woof! Woof!public means properties are accessible outside the class.extends enables class inheritance.public, private, protected)Control class property accessibility.
class Person {
public name: string; // Accessible anywhere
private age: number; // Accessible only within the class
protected email: string; // Accessible in subclasses
constructor(name: string, age: number, email: string) {
this.name = name;
this.age = age;
this.email = email;
}
}
class Employee extends Person {
showEmail(): string {
return this.email; // Works because email is protected
}
}
const emp = new Employee("Alice", 30, "alice@example.com");
// emp.age = 30; // Error: Property 'age' is privateimport / export)Break code into multiple files using modules.
// mathUtils.ts (exporting)
export function add(a: number, b: number): number {
return a + b;
}
// main.ts (importing)
import { add } from "./mathUtils";
console.log(add(2, 3)); // 5export makes functions, variables, or classes available to other files.import brings in exported members.TypeScript provides full support for async programming.
function fetchData(): Promise<string> {
return new Promise((resolve) => {
setTimeout(() => resolve("Data received"), 1000);
});
}
// Using Promises
fetchData().then(console.log);
// Using Async/Await
async function getData() {
const data = await fetchData();
console.log(data);
}
getData();Enable strict mode to enforce safe handling of null and undefined.
let value: string | null = null;
function printLength(str?: string): void {
console.log(str?.length ?? "No string provided");
}
printLength("Hello"); // 5
printLength(); // "No string provided"?. (optional chaining) to avoid errors on null or undefined.?? (nullish coalescing) to provide default values.typeof and instanceofCheck types at runtime.
let input: unknown = "Hello";
if (typeof input === "string") {
console.log(input.toUpperCase());
}
class Car {}
const myCar = new Car();
console.log(myCar instanceof Car); // trueThis cheat sheet covers the most commonly used TypeScript features. Keep it handy as a quick reference when working with TypeScript.
🚀 Next Steps:
- Try using TypeScript in a real project.
- Experiment with generics, modules, and async programming.
- Explore advanced types like mapped types, utility types, and discriminated unions.
Happy coding! 🎯
🎉 Congratulations! You’ve built a mini full-stack application using TypeScript on both the server and client, and you’ve run it in Docker containers. In doing so, you learned how to set up a Node.js project with TypeScript, how to create a React app with TypeScript, how to use npm/npx and scripts to manage tasks, and how to use Docker and Docker Compose to streamline development and ensure a consistent environment.
Next Steps: From here, you can expand this project with more routes, more complex data, perhaps a database, or add more React components. TypeScript will scale with you, catching errors as your application grows. You might also explore other frameworks or tools: - Use a more advanced build tool like Webpack or Vite for bundling (Create React App uses Webpack under the hood, but tools like Vite can be even faster for dev). - Explore different TypeScript config options (strict mode flags, path aliases, etc.). - Add testing to your project (Jest for React/TypeScript, for instance). - Learn more about TypeScript’s advanced type system (Union types, intersection types, mapped types, etc.) as you build more complex features.
But even without those, you now have a solid starting point for developing with TypeScript in a modern web stack. Happy coding!