TypeScript

Building a Full-Stack TypeScript Project - Part 1

30 min Lesson 39 of 40

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:
  1. Initialize the monorepo structure with workspaces
  2. Create the shared types package with User and Task types
  3. Set up the backend with Prisma and PostgreSQL
  4. Implement the AuthService with JWT token generation
  5. Implement the TaskService with CRUD operations
  6. Create auth and task routes with proper typing
  7. 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.