Building a Full-Stack Application with TypeScript
TypeScript has become the de facto standard for building scalable JavaScript applications. In this post, we'll explore how to build a complete full-stack application using TypeScript on both the frontend and backend.
Why TypeScript?
TypeScript offers several advantages for full-stack development:
- Type Safety: Catch errors at compile-time rather than runtime
- Better IDE Support: Enhanced autocomplete, refactoring, and navigation
- Improved Code Quality: Self-documenting code with type annotations
- Easier Refactoring: Confident code changes with type checking
Setting Up the Backend
Let's start with a Node.js backend using Express:
mkdir my-app-backend
cd my-app-backend
npm init -y
npm install express cors dotenv
npm install -D typescript @types/node @types/express @types/cors ts-node-dev
Create a tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
Creating Type Definitions
Define shared types that both frontend and backend can use:
// types/User.ts
export interface User {
id: string;
name: string;
email: string;
createdAt: Date;
}
export interface CreateUserRequest {
name: string;
email: string;
}
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
}
Backend Implementation
// src/server.ts
import express from 'express';
import cors from 'cors';
import { User, CreateUserRequest, ApiResponse } from '../types/User';
const app = express();
const PORT = process.env.PORT || 3001;
app.use(cors());
app.use(express.json());
// In-memory storage (use a real database in production)
let users: User[] = [];
app.get('/api/users', (req, res) => {
const response: ApiResponse<User[]> = {
success: true,
data: users
};
res.json(response);
});
app.post('/api/users', (req, res) => {
const { name, email }: CreateUserRequest = req.body;
const newUser: User = {
id: Date.now().toString(),
name,
email,
createdAt: new Date()
};
users.push(newUser);
const response: ApiResponse<User> = {
success: true,
data: newUser
};
res.status(201).json(response);
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Frontend Setup
For the frontend, let's use React with TypeScript:
npx create-react-app my-app-frontend --template typescript
cd my-app-frontend
npm install axios
Frontend Implementation
// src/services/userService.ts
import axios from 'axios';
import { User, CreateUserRequest, ApiResponse } from '../types/User';
const API_BASE_URL = 'http://localhost:3001/api';
export const userService = {
async getUsers(): Promise<User[]> {
const response = await axios.get<ApiResponse<User[]>>(`${API_BASE_URL}/users`);
return response.data.data || [];
},
async createUser(userData: CreateUserRequest): Promise<User> {
const response = await axios.post<ApiResponse<User>>(`${API_BASE_URL}/users`, userData);
if (!response.data.success) {
throw new Error(response.data.error || 'Failed to create user');
}
return response.data.data!;
}
};
React Component with TypeScript
// src/components/UserList.tsx
import React, { useState, useEffect } from 'react';
import { User, CreateUserRequest } from '../types/User';
import { userService } from '../services/userService';
const UserList: React.FC = () => {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [newUser, setNewUser] = useState<CreateUserRequest>({
name: '',
email: ''
});
useEffect(() => {
loadUsers();
}, []);
const loadUsers = async () => {
try {
const userData = await userService.getUsers();
setUsers(userData);
} catch (error) {
console.error('Error loading users:', error);
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const createdUser = await userService.createUser(newUser);
setUsers([...users, createdUser]);
setNewUser({ name: '', email: '' });
} catch (error) {
console.error('Error creating user:', error);
}
};
if (loading) return <div>Loading...</div>;
return (
<div>
<h2>Users</h2>
<form onSubmit={handleSubmit}>
<input
type="text"
placeholder="Name"
value={newUser.name}
onChange={(e) => setNewUser({ ...newUser, name: e.target.value })}
required
/>
<input
type="email"
placeholder="Email"
value={newUser.email}
onChange={(e) => setNewUser({ ...newUser, email: e.target.value })}
required
/>
<button type="submit">Add User</button>
</form>
<ul>
{users.map((user) => (
<li key={user.id}>
{user.name} - {user.email}
</li>
))}
</ul>
</div>
);
};
export default UserList;
Best Practices
- Share Type Definitions: Create a common types package for both frontend and backend
- Use Strict Mode: Enable strict TypeScript compilation options
- Implement Proper Error Handling: Use typed error responses
- Validate Input: Use libraries like Zod for runtime type validation
- Use Generic Types: Make your API responses type-safe with generics
Conclusion
Building full-stack applications with TypeScript provides excellent developer experience and helps maintain code quality as your application grows. The type safety and tooling support make it worth the initial setup investment.
Start your next project with TypeScript and experience the benefits of type-safe full-stack development!