Building a Full-Stack Application with TypeScript

TypeScriptFull-StackNode.jsReact

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

  1. Share Type Definitions: Create a common types package for both frontend and backend
  2. Use Strict Mode: Enable strict TypeScript compilation options
  3. Implement Proper Error Handling: Use typed error responses
  4. Validate Input: Use libraries like Zod for runtime type validation
  5. 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!