TypeScript
Building a Full-Stack TypeScript Project - Part 1
Building a Full-Stack TypeScript Project - Part 1
In this two-part lesson, we'll build a complete full-stack TypeScript application: a task management system with authentication, real-time updates, and a modern frontend. Part 1 covers project setup, shared types, API layer, and frontend architecture.
Project Overview
We'll build a task management system with:
- Backend: Node.js + Express + TypeScript + Prisma
- Frontend: React + TypeScript + Vite
- Database: PostgreSQL
- Real-time: WebSocket support
- Authentication: JWT-based auth
Project Structure
task-manager/
├── packages/
│ ├── shared/ # Shared types and utilities
│ │ ├── src/
│ │ │ ├── types/
│ │ │ │ ├── user.ts
│ │ │ │ ├── task.ts
│ │ │ │ └── api.ts
│ │ │ └── index.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── backend/ # Express API
│ │ ├── src/
│ │ │ ├── routes/
│ │ │ ├── controllers/
│ │ │ ├── services/
│ │ │ ├── middleware/
│ │ │ └── server.ts
│ │ ├── prisma/
│ │ ├── package.json
│ │ └── tsconfig.json
│ └── frontend/ # React app
│ ├── src/
│ │ ├── components/
│ │ ├── hooks/
│ │ ├── services/
│ │ └── App.tsx
│ ├── package.json
│ └── tsconfig.json
├── package.json # Root package (workspace)
└── tsconfig.json # Base config
Step 1: Initialize Monorepo
# Create project
mkdir task-manager && cd task-manager
# Initialize root package
npm init -y
# Configure as workspace
# Edit package.json:
{
"name": "task-manager",
"private": true,
"workspaces": [
"packages/*"
]
}
Step 2: Shared Types Package
# Create shared package
mkdir -p packages/shared/src/types
cd packages/shared
npm init -y
npm install typescript --save-dev
// packages/shared/tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
// packages/shared/src/types/user.ts
export interface User {
id: string;
email: string;
name: string;
createdAt: Date;
updatedAt: Date;
}
export interface UserCreate {
email: string;
name: string;
password: string;
}
export interface UserLogin {
email: string;
password: string;
}
export interface UserUpdate {
name?: string;
email?: string;
}
export type UserPublic = Omit<User, 'password'>;
// packages/shared/src/types/task.ts
export type TaskStatus = 'pending' | 'in-progress' | 'completed' | 'cancelled';
export type TaskPriority = 'low' | 'medium' | 'high' | 'urgent';
export interface Task {
id: string;
title: string;
description: string | null;
status: TaskStatus;
priority: TaskPriority;
dueDate: Date | null;
userId: string;
createdAt: Date;
updatedAt: Date;
}
export interface TaskCreate {
title: string;
description?: string;
status?: TaskStatus;
priority?: TaskPriority;
dueDate?: Date;
}
export interface TaskUpdate {
title?: string;
description?: string | null;
status?: TaskStatus;
priority?: TaskPriority;
dueDate?: Date | null;
}
export interface TaskFilter {
status?: TaskStatus;
priority?: TaskPriority;
search?: string;
userId?: string;
}
// packages/shared/src/types/api.ts
// Generic API response types
export interface ApiResponse<T> {
data: T;
message?: string;
}
export interface ApiError {
error: string;
details?: unknown;
statusCode: number;
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export interface PaginationParams {
page?: number;
pageSize?: number;
}
// Authentication types
export interface AuthTokens {
accessToken: string;
refreshToken: string;
}
export interface AuthResponse {
user: UserPublic;
tokens: AuthTokens;
}
// WebSocket message types
export type WsEventType = 'task:created' | 'task:updated' | 'task:deleted';
export interface WsMessage<T = unknown> {
type: WsEventType;
payload: T;
timestamp: number;
}
// packages/shared/src/index.ts
export * from './types/user';
export * from './types/task';
export * from './types/api';
// packages/shared/package.json
{
"name": "@task-manager/shared",
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"watch": "tsc --watch"
},
"devDependencies": {
"typescript": "^5.0.0"
}
}
Step 3: Backend Setup
# Create backend package
mkdir -p packages/backend/src
cd packages/backend
npm init -y
# Install dependencies
npm install express cors dotenv bcrypt jsonwebtoken
npm install @task-manager/shared
# Install dev dependencies
npm install --save-dev typescript @types/node @types/express \
@types/cors @types/bcrypt @types/jsonwebtoken \
ts-node nodemon prisma @prisma/client
// packages/backend/tsconfig.json
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"module": "commonjs",
"target": "ES2020"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
// packages/backend/prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid())
email String @unique
name String
password String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tasks Task[]
}
model Task {
id String @id @default(uuid())
title String
description String?
status String @default("pending")
priority String @default("medium")
dueDate DateTime?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([status])
}
Step 4: Backend Services
// packages/backend/src/services/auth.service.ts
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { PrismaClient } from '@prisma/client';
import type { UserCreate, UserLogin, AuthTokens, UserPublic } from '@task-manager/shared';
const prisma = new PrismaClient();
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'your-refresh-secret';
export class AuthService {
async register(data: UserCreate): Promise<UserPublic> {
const existingUser = await prisma.user.findUnique({
where: { email: data.email }
});
if (existingUser) {
throw new Error('Email already registered');
}
const hashedPassword = await bcrypt.hash(data.password, 10);
const user = await prisma.user.create({
data: {
email: data.email,
name: data.name,
password: hashedPassword
}
});
return this.toPublicUser(user);
}
async login(data: UserLogin): Promise<{ user: UserPublic; tokens: AuthTokens }> {
const user = await prisma.user.findUnique({
where: { email: data.email }
});
if (!user) {
throw new Error('Invalid credentials');
}
const validPassword = await bcrypt.compare(data.password, user.password);
if (!validPassword) {
throw new Error('Invalid credentials');
}
const tokens = this.generateTokens(user.id);
return {
user: this.toPublicUser(user),
tokens
};
}
generateTokens(userId: string): AuthTokens {
const accessToken = jwt.sign({ userId }, JWT_SECRET, { expiresIn: '15m' });
const refreshToken = jwt.sign({ userId }, JWT_REFRESH_SECRET, { expiresIn: '7d' });
return { accessToken, refreshToken };
}
verifyAccessToken(token: string): { userId: string } {
try {
return jwt.verify(token, JWT_SECRET) as { userId: string };
} catch {
throw new Error('Invalid token');
}
}
private toPublicUser(user: any): UserPublic {
const { password, ...publicUser } = user;
return publicUser;
}
}
// packages/backend/src/services/task.service.ts
import { PrismaClient } from '@prisma/client';
import type { Task, TaskCreate, TaskUpdate, TaskFilter, PaginatedResponse } from '@task-manager/shared';
const prisma = new PrismaClient();
export class TaskService {
async create(userId: string, data: TaskCreate): Promise<Task> {
return prisma.task.create({
data: {
title: data.title,
description: data.description,
status: data.status || 'pending',
priority: data.priority || 'medium',
dueDate: data.dueDate,
userId
}
}) as Promise<Task>;
}
async findAll(
userId: string,
filter: TaskFilter = {},
page = 1,
pageSize = 20
): Promise<PaginatedResponse<Task>> {
const where: any = { userId };
if (filter.status) where.status = filter.status;
if (filter.priority) where.priority = filter.priority;
if (filter.search) {
where.OR = [
{ title: { contains: filter.search, mode: 'insensitive' } },
{ description: { contains: filter.search, mode: 'insensitive' } }
];
}
const [tasks, total] = await Promise.all([
prisma.task.findMany({
where,
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { createdAt: 'desc' }
}),
prisma.task.count({ where })
]);
return {
data: tasks as Task[],
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize)
};
}
async findById(id: string, userId: string): Promise<Task | null> {
return prisma.task.findFirst({
where: { id, userId }
}) as Promise<Task | null>;
}
async update(id: string, userId: string, data: TaskUpdate): Promise<Task> {
// Verify ownership
const task = await this.findById(id, userId);
if (!task) {
throw new Error('Task not found');
}
return prisma.task.update({
where: { id },
data
}) as Promise<Task>;
}
async delete(id: string, userId: string): Promise<void> {
// Verify ownership
const task = await this.findById(id, userId);
if (!task) {
throw new Error('Task not found');
}
await prisma.task.delete({ where: { id } });
}
}
Step 5: Backend Middleware
// packages/backend/src/middleware/auth.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { AuthService } from '../services/auth.service';
const authService = new AuthService();
export interface AuthRequest extends Request {
userId?: string;
}
export function authMiddleware(
req: AuthRequest,
res: Response,
next: NextFunction
): void {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.status(401).json({ error: 'No token provided' });
return;
}
const token = authHeader.substring(7);
const { userId } = authService.verifyAccessToken(token);
req.userId = userId;
next();
} catch (error) {
res.status(401).json({ error: 'Invalid token' });
}
}
// packages/backend/src/middleware/error.middleware.ts
import { Request, Response, NextFunction } from 'express';
import type { ApiError } from '@task-manager/shared';
export function errorMiddleware(
error: Error,
req: Request,
res: Response,
next: NextFunction
): void {
console.error('Error:', error);
const apiError: ApiError = {
error: error.message || 'Internal server error',
statusCode: 500
};
res.status(apiError.statusCode).json(apiError);
}
Note: We're using a monorepo structure with shared types between backend and frontend. This ensures type safety across the entire application and eliminates type drift.
Step 6: Backend Routes
// packages/backend/src/routes/auth.routes.ts
import { Router } from 'express';
import { AuthService } from '../services/auth.service';
import type { UserCreate, UserLogin, ApiResponse, AuthResponse } from '@task-manager/shared';
const router = Router();
const authService = new AuthService();
router.post('/register', async (req, res, next) => {
try {
const data: UserCreate = req.body;
const user = await authService.register(data);
const tokens = authService.generateTokens(user.id);
const response: ApiResponse<AuthResponse> = {
data: { user, tokens }
};
res.status(201).json(response);
} catch (error) {
next(error);
}
});
router.post('/login', async (req, res, next) => {
try {
const data: UserLogin = req.body;
const result = await authService.login(data);
const response: ApiResponse<AuthResponse> = {
data: result
};
res.json(response);
} catch (error) {
next(error);
}
});
export default router;
// packages/backend/src/routes/task.routes.ts
import { Router } from 'express';
import { TaskService } from '../services/task.service';
import { authMiddleware, AuthRequest } from '../middleware/auth.middleware';
import type { TaskCreate, TaskUpdate, ApiResponse, Task, PaginatedResponse } from '@task-manager/shared';
const router = Router();
const taskService = new TaskService();
// All routes require authentication
router.use(authMiddleware);
router.post('/', async (req: AuthRequest, res, next) => {
try {
const data: TaskCreate = req.body;
const task = await taskService.create(req.userId!, data);
const response: ApiResponse<Task> = { data: task };
res.status(201).json(response);
} catch (error) {
next(error);
}
});
router.get('/', async (req: AuthRequest, res, next) => {
try {
const { page, pageSize, ...filter } = req.query;
const result = await taskService.findAll(
req.userId!,
filter as any,
page ? parseInt(page as string) : undefined,
pageSize ? parseInt(pageSize as string) : undefined
);
const response: ApiResponse<PaginatedResponse<Task>> = { data: result };
res.json(response);
} catch (error) {
next(error);
}
});
router.get('/:id', async (req: AuthRequest, res, next) => {
try {
const task = await taskService.findById(req.params.id, req.userId!);
if (!task) {
res.status(404).json({ error: 'Task not found' });
return;
}
const response: ApiResponse<Task> = { data: task };
res.json(response);
} catch (error) {
next(error);
}
});
router.put('/:id', async (req: AuthRequest, res, next) => {
try {
const data: TaskUpdate = req.body;
const task = await taskService.update(req.params.id, req.userId!, data);
const response: ApiResponse<Task> = { data: task };
res.json(response);
} catch (error) {
next(error);
}
});
router.delete('/:id', async (req: AuthRequest, res, next) => {
try {
await taskService.delete(req.params.id, req.userId!);
res.status(204).send();
} catch (error) {
next(error);
}
});
export default router;
Tip: Notice how we import types from the shared package. TypeScript ensures that backend requests and responses match the expected types, preventing API contract drift.
Exercise: Set up your project:
- Initialize the monorepo structure with workspaces
- Create the shared types package with User and Task types
- Set up the backend with Prisma and PostgreSQL
- Implement the AuthService with JWT token generation
- Implement the TaskService with CRUD operations
- Create auth and task routes with proper typing
- Test the API endpoints using Postman or similar tools
Summary
In Part 1, we set up a type-safe full-stack project structure with shared types, backend API, authentication, and task management. We used TypeScript throughout to ensure type safety from database to API. In Part 2, we'll build the frontend, implement real-time features, add testing, and deploy the application.