TypeScript Tutorial: Building a Simple Web Application for a Code Review System

In the fast-paced world of software development, code reviews are a cornerstone of quality and collaboration. They help catch bugs, ensure code adheres to standards, and spread knowledge among team members. But managing the code review process can be cumbersome, especially in large projects or distributed teams. Imagine the challenges: tracking changes, assigning reviewers, managing feedback, and ensuring all issues are addressed. This is where a dedicated code review system can shine, streamlining the process and making it more efficient.

Why Build a Code Review System?

While there are many excellent code review tools available, building your own can be a valuable learning experience. It allows you to:

  • Deepen your understanding of web development: You’ll get hands-on experience with front-end and back-end technologies.
  • Tailor the system to your needs: You can customize the features and workflows to perfectly fit your team’s specific requirements.
  • Improve your TypeScript skills: Building a project from scratch is a fantastic way to practice and solidify your TypeScript knowledge.
  • Boost your portfolio: A well-designed code review system is a great project to showcase your abilities to potential employers.

Project Overview: The Code Review System

In this tutorial, we’ll build a simplified code review system using TypeScript. This system will allow users to:

  • Submit code changes.
  • Assign reviewers.
  • Review code changes.
  • Provide feedback (comments).
  • Track the status of reviews.

We’ll focus on the core functionality, providing a solid foundation that you can expand upon. We’ll use a basic front-end interface (HTML, CSS, and JavaScript) to interact with a back-end built with Node.js and Express.js, although the exact implementation of the back-end is outside the scope of this particular tutorial. The key here is the TypeScript code that defines the data structures and behaviors of the system.

Setting Up the Development Environment

Before we dive into the code, let’s set up our development environment. You’ll need the following:

  • Node.js and npm (or yarn): These are essential for managing packages and running our server. Download and install them from https://nodejs.org/.
  • A code editor: VS Code is highly recommended, but you can use any editor you prefer (Sublime Text, Atom, etc.).
  • TypeScript: Install the TypeScript compiler globally using npm: npm install -g typescript.

Once you’ve installed these, create a new project directory and initialize a Node.js project:

mkdir code-review-system
cd code-review-system
npm init -y

Next, install the necessary dependencies. We’ll need TypeScript, a type definition library for Node.js, and a few others that we’ll add later as needed for our back-end server (e.g., Express.js).

npm install typescript @types/node --save-dev

Now, create a tsconfig.json file in your project root. This file configures the TypeScript compiler. You can generate a basic one using the TypeScript compiler itself:

npx tsc --init

This will create a `tsconfig.json` file. You can customize it for your project. Here’s a recommended configuration:

{
  "compilerOptions": {
    "target": "es2016",
    "module": "commonjs",
    "outDir": "./dist",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}

Let’s break down the key options:

  • target: Specifies the JavaScript version to compile to. es2016 is a good starting point.
  • module: Specifies the module system. commonjs is common for Node.js.
  • outDir: Specifies the output directory for the compiled JavaScript files.
  • esModuleInterop: Enables interoperability between CommonJS and ES modules.
  • forceConsistentCasingInFileNames: Enforces consistent casing in file names.
  • strict: Enables strict type checking. Highly recommended for catching errors early.
  • skipLibCheck: Skips type checking of declaration files (.d.ts).
  • include: Specifies the files to include in the compilation.

Defining Data Structures with TypeScript

One of the great strengths of TypeScript is its ability to define data structures using interfaces and types. This allows us to catch errors during development and makes our code more maintainable. Let’s define the core data structures for our code review system.

Create a directory named `src` and inside it, create a file named `models.ts`. This file will contain our type definitions.


// models.ts

// Represents a user
interface User {
  id: number;
  username: string;
  email: string;
}

// Represents a code change (e.g., a git commit)
interface CodeChange {
  id: number;
  authorId: number;  // Reference to a User
  title: string;
  description: string;
  code: string;  // The actual code changes (could be a diff or the full file content)
  createdAt: Date;
}

// Represents a comment on a code change
interface Comment {
  id: number;
  codeChangeId: number; // Reference to a CodeChange
  authorId: number; // Reference to a User
  content: string;
  createdAt: Date;
}

// Represents a code review
interface CodeReview {
  id: number;
  codeChangeId: number;  // Reference to a CodeChange
  reviewerId: number; // Reference to a User
  status: ReviewStatus;  // 'open', 'in_progress', 'approved', 'rejected'
  comments: Comment[]; // Array of comments associated with this review
  createdAt: Date;
  updatedAt: Date;
}

// Enum for review statuses
enum ReviewStatus {
  Open = 'open',
  InProgress = 'in_progress',
  Approved = 'approved',
  Rejected = 'rejected',
}

export { User, CodeChange, Comment, CodeReview, ReviewStatus };

Let’s break down these interfaces and the enum:

  • User: Represents a user in the system. We have basic properties like `id`, `username`, and `email`.
  • CodeChange: Represents a code change. It includes the author (`authorId`), the title, a description, the code itself (which could be a diff or the entire file content), and a timestamp. Note the use of `authorId`, which links to a `User` using their `id`. This demonstrates how we can create relationships between data.
  • Comment: Represents a comment on a code change. It includes the author (`authorId`), the content of the comment, and a timestamp. It also links to the `CodeChange` using `codeChangeId`.
  • CodeReview: Represents a code review. It links to the `CodeChange` being reviewed, the assigned reviewer (`reviewerId`), the status of the review (using our `ReviewStatus` enum), an array of associated `comments`, and timestamps for creation and updates.
  • ReviewStatus: An enum that defines the possible states of a code review. Using an enum helps ensure that the `status` property can only have valid values, preventing typos and making our code more robust.

These interfaces provide a clear blueprint for the data that our code review system will handle. They define the shape of our data, making our code easier to understand and less prone to errors. We’ve used `number` for IDs, `string` for text-based fields, `Date` for timestamps, and the custom `ReviewStatus` enum. You can adjust these types based on your specific needs (e.g., using a more specific type for the `code` property, such as a string representing a diff).

Creating Basic API Endpoints (Conceptual)

While the focus of this tutorial is TypeScript, let’s briefly touch on how these data structures would be used in a back-end API. We won’t implement the back-end fully, but we’ll outline the key endpoints.

Imagine you are using Node.js and Express.js for your back-end. You would need to install these packages: npm install express @types/express. Then, you’d create routes that handle requests to your API. Here are some example endpoints, along with their expected request methods and data (using the types we defined):

  • POST /code-changes: Create a new code change. Expects a `CodeChange` object in the request body (excluding the `id` which would be generated by the server).
  • GET /code-changes/{id}: Get a specific code change by its ID. Returns a `CodeChange` object.
  • GET /code-changes: Get a list of all code changes. Returns an array of `CodeChange` objects. You might include query parameters for filtering (e.g., by author, status, etc.).
  • POST /code-reviews: Create a new code review. Expects a request body containing `codeChangeId` and `reviewerId`.
  • GET /code-reviews/{id}: Get a specific code review. Returns a `CodeReview` object.
  • PUT /code-reviews/{id}: Update a code review (e.g., change the status). Expects a partial `CodeReview` object in the request body (e.g., just the `status`).
  • POST /comments: Add a comment to a code change. Expects a `Comment` object in the request body (excluding the `id`).
  • GET /comments/code-change/{codeChangeId}: Get all comments for a specific code change. Returns an array of `Comment` objects.

The back-end would handle the database interactions (e.g., using a database like PostgreSQL or MongoDB) to store and retrieve the data. These endpoints would use the TypeScript interfaces we defined to validate the data, ensuring that the API receives and returns data in the correct format. This type safety is a major benefit of using TypeScript, significantly reducing the chance of errors.

Implementing a Simple Code Review Component (Conceptual)

Now, let’s think about how we might build a simple front-end component using TypeScript to display a code review. This would be a React component, but the same concepts apply to other front-end frameworks (Angular, Vue.js, etc.).

Let’s create a file called `CodeReviewComponent.tsx` (using the `.tsx` extension to indicate that it contains JSX, the syntax used by React). We will not implement the actual component logic, but provide a conceptual outline.


// CodeReviewComponent.tsx
import React, { useState, useEffect } from 'react';
import { CodeReview, CodeChange, Comment, ReviewStatus } from './models'; // Import our types

interface CodeReviewComponentProps {
  reviewId: number; // The ID of the code review to display
}

const CodeReviewComponent: React.FC<CodeReviewComponentProps> = ({ reviewId }) => {
  const [codeReview, setCodeReview] = useState<CodeReview | null>(null);
  const [codeChange, setCodeChange] = useState<CodeChange | null>(null);
  const [comments, setComments] = useState<Comment[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const fetchData = async () => {
      try {
        setLoading(true);
        setError(null);

        // Fetch the code review
        const reviewResponse = await fetch(`/api/code-reviews/${reviewId}`);
        if (!reviewResponse.ok) {
          throw new Error(`Failed to fetch code review: ${reviewResponse.status}`);
        }
        const reviewData: CodeReview = await reviewResponse.json();
        setCodeReview(reviewData);

        // Fetch the code change
        const changeResponse = await fetch(`/api/code-changes/${reviewData.codeChangeId}`);
        if (!changeResponse.ok) {
          throw new Error(`Failed to fetch code change: ${changeResponse.status}`);
        }
        const changeData: CodeChange = await changeResponse.json();
        setCodeChange(changeData);

        // Fetch the comments
        const commentsResponse = await fetch(`/api/comments/code-change/${reviewData.codeChangeId}`);
        if (!commentsResponse.ok) {
          throw new Error(`Failed to fetch comments: ${commentsResponse.status}`);
        }
        const commentsData: Comment[] = await commentsResponse.json();
        setComments(commentsData);

      } catch (err: any) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, [reviewId]);

  if (loading) {
    return <p>Loading...</p>;
  }

  if (error) {
    return <p>Error: {error}</p>;
  }

  if (!codeReview || !codeChange) {
    return <p>Code review not found.</p>
  }

  return (
    <div>
      <h2>Code Review #{codeReview.id}</h2>
      <p>Status: {codeReview.status}</p>
      <h3>Code Change: {codeChange.title}</h3>
      <p>{codeChange.description}</p>
      <pre><code>{codeChange.code}</code></pre>  // Display the code
      <h3>Comments</h3>
      <ul>
        {comments.map(comment => (
          <li key={comment.id}>
            <p>{comment.content}</p>
            <p>By User ID: {comment.authorId} </p>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default CodeReviewComponent;

Let’s break down this component:

  • Imports: We import `React`, `useState`, `useEffect` from `react`, and our interfaces from `models.ts`.
  • Props: The component receives a `reviewId` prop (of type `number`), which represents the ID of the code review to display.
  • State: We use the `useState` hook to manage the component’s state. We have state variables for:
    • `codeReview`: The `CodeReview` object fetched from the API.
    • `codeChange`: The `CodeChange` object fetched from the API.
    • `comments`: An array of `Comment` objects fetched from the API.
    • `loading`: A boolean indicating whether the data is being loaded.
    • `error`: A string to store any error messages.
  • useEffect Hook: This hook is used to fetch the code review data when the component mounts.
    • Inside the `useEffect` hook, the `fetchData` function is defined as an `async` function.
    • It fetches the `CodeReview`, `CodeChange`, and `Comments` from the API using the `fetch` API.
    • It updates the state with the fetched data.
    • It handles potential errors using `try…catch` blocks and sets the `error` state.
    • The `finally` block ensures that `loading` is set to `false` regardless of success or failure.
  • Conditional Rendering: The component renders different content based on the state. It shows a “Loading…” message while the data is being fetched, an error message if an error occurs, and the code review details once the data is available.
  • JSX: The component uses JSX to render the data. It displays the code review status, code change title and description, the code itself (within a <pre><code> block for formatting), and a list of comments.

This example demonstrates how TypeScript and React work together to build a type-safe and modular front-end component. The type definitions from `models.ts` ensure that the data fetched from the API conforms to the expected structure, reducing the risk of runtime errors.

Adding Validation (Conceptual)

While TypeScript provides static typing, which helps catch errors at compile time, we can also add runtime validation to our code. This is particularly important when working with data coming from external sources (like an API), where the structure might not always be guaranteed to match our TypeScript types.

For this purpose, we can use a library like `zod` or `yup`. These libraries allow us to define schemas that describe the shape of our data and validate it at runtime. Let’s consider an example of how you might use `zod` to validate the data returned from an API call.

First, install `zod`:

npm install zod

Then, in your component (e.g., `CodeReviewComponent.tsx`), you would import `zod` and define a schema for the data you expect from the API. Here’s an example:


import * as z from 'zod';
import { CodeReview, CodeChange, Comment } from './models';

// Define Zod schemas for validation
const codeReviewSchema = z.object({
  id: z.number(),
  codeChangeId: z.number(),
  reviewerId: z.number(),
  status: z.enum(['open', 'in_progress', 'approved', 'rejected']),
  comments: z.array(z.object({
    id: z.number(),
    codeChangeId: z.number(),
    authorId: z.number(),
    content: z.string(),
    createdAt: z.string().datetime(), // Validate the date string
  })),
  createdAt: z.string().datetime(),
  updatedAt: z.string().datetime(),
});

const codeChangeSchema = z.object({
  id: z.number(),
  authorId: z.number(),
  title: z.string(),
  description: z.string(),
  code: z.string(),
  createdAt: z.string().datetime(),
});

const commentSchema = z.object({
    id: z.number(),
    codeChangeId: z.number(),
    authorId: z.number(),
    content: z.string(),
    createdAt: z.string().datetime(),
});

const CodeReviewComponent: React.FC<CodeReviewComponentProps> = ({ reviewId }) => {
  // ... (rest of the component code)

  useEffect(() => {
    const fetchData = async () => {
      try {
        setLoading(true);
        setError(null);

        // Fetch the code review
        const reviewResponse = await fetch(`/api/code-reviews/${reviewId}`);
        if (!reviewResponse.ok) {
          throw new Error(`Failed to fetch code review: ${reviewResponse.status}`);
        }
        const reviewData = await reviewResponse.json();

        // Validate the code review data
        const validatedReview: CodeReview = codeReviewSchema.parse(reviewData);
        setCodeReview(validatedReview);

        // Fetch the code change
        const changeResponse = await fetch(`/api/code-changes/${reviewData.codeChangeId}`);
        if (!changeResponse.ok) {
          throw new Error(`Failed to fetch code change: ${changeResponse.status}`);
        }
        const changeData = await changeResponse.json();

        // Validate the code change data
        const validatedChange: CodeChange = codeChangeSchema.parse(changeData);
        setCodeChange(validatedChange);

        // Fetch the comments
        const commentsResponse = await fetch(`/api/comments/code-change/${reviewData.codeChangeId}`);
        if (!commentsResponse.ok) {
          throw new Error(`Failed to fetch comments: ${commentsResponse.status}`);
        }
        const commentsData = await commentsResponse.json();

        // Validate comments data
        const validatedComments: Comment[] = commentsSchema.array().parse(commentsData);
        setComments(validatedComments);

      } catch (err: any) {
        if (err instanceof z.ZodError) {
          setError(`Validation error: ${err.errors.map(e => e.message).join(', ')}`);
        } else {
          setError(err.message);
        }
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, [reviewId]);

  // ... (rest of the component code)
}

In this example:

  • We import `zod`.
  • We define `zod` schemas that mirror the structure of our `CodeReview`, `CodeChange`, and `Comment` interfaces. The schemas use `z.object` to define the shape of the objects, and specific methods (like `z.number()`, `z.string()`, `z.enum()`, `z.array()`, and `z.string().datetime()`) to specify the expected data types and constraints. The `.datetime()` method is used to validate the format of the date strings.
  • Before setting the state, we use the `.parse()` method of the schema to validate the data returned from the API. If the data doesn’t match the schema, `zod` will throw a `ZodError`.
  • We catch the `ZodError` and display a more informative error message to the user, including the specific validation errors.

This approach adds an extra layer of protection, ensuring that your application is more robust and can handle unexpected data from external sources gracefully.

Common Mistakes and How to Fix Them

When building TypeScript applications, especially as a beginner, you might encounter some common pitfalls. Here’s a look at some of them and how to overcome them:

  • Incorrect Type Annotations: One of the most common mistakes is providing incorrect type annotations. For example, you might annotate a variable as `string` when it should be `number`, or you might forget to include a type annotation altogether, leading to the `any` type (which defeats the purpose of TypeScript).
    • Fix: Carefully review your code and make sure that your type annotations accurately reflect the data types you’re working with. Use the TypeScript compiler to help you identify type errors. Use the `–noImplicitAny` compiler option in your `tsconfig.json` to prevent implicit `any` types.
  • Ignoring Compiler Errors: The TypeScript compiler is your friend! Don’t ignore the error messages it provides. These messages are designed to help you catch errors early in the development process.
    • Fix: Read the compiler error messages carefully. They often provide valuable information about the problem and how to fix it. Use your code editor’s features to quickly navigate to the lines of code that have errors.
  • Mixing `any` with Typed Code: Using the `any` type too liberally negates many of the benefits of TypeScript. It essentially tells the compiler to turn off type checking for a specific variable or expression.
    • Fix: Avoid using `any` unless absolutely necessary. If you’re not sure of the type, try to infer it from the context or use a more specific type. Use interfaces and types to define the shape of your data. If you’re working with a library that doesn’t have TypeScript definitions, you can often find them on DefinitelyTyped (a repository of TypeScript definition files).
  • Incorrect Module Imports: Importing modules incorrectly can lead to errors. This is especially true when working with ES modules and CommonJS modules.
    • Fix: Make sure you’re importing modules correctly. For example, if you’re using ES modules, use the `import` statement. If you’re using CommonJS modules, use the `require` function. Check the documentation of the library you’re using to understand how to import its modules. Ensure that the paths in your import statements are correct relative to your source files.
  • Not Using Interfaces/Types Effectively: Interfaces and types are the backbone of TypeScript. Not using them effectively can lead to less maintainable and more error-prone code.
    • Fix: Use interfaces and types to define the shape of your data. Use them to create reusable data structures and to improve the readability of your code. Use interfaces to define the structure of objects and types to create aliases for complex types.
  • Improper Handling of Asynchronous Operations: Asynchronous operations (e.g., fetching data from an API) can be tricky to handle. Failing to handle them correctly can lead to unexpected behavior.
    • Fix: Use `async/await` to write asynchronous code that is easier to read and understand. Handle potential errors using `try…catch` blocks. Make sure you’re properly handling promises. Use the `useEffect` hook in React (or similar mechanisms in other frameworks) to manage side effects, such as fetching data, in a controlled way.
  • Not Understanding `this` Binding: The behavior of `this` can be confusing, especially in JavaScript. TypeScript doesn’t change the underlying JavaScript behavior of `this`, so you still need to be aware of how it works.
    • Fix: Use arrow functions (`() => {}`) to automatically bind `this` to the surrounding context. Use the `bind()` method to explicitly bind `this` to a specific object. Be mindful of how you’re calling functions and where `this` will be bound. Use the `this: Type` annotation in your function definitions to provide more explicit type checking for the `this` context.
  • Overlooking Type Narrowing: Type narrowing is a powerful feature of TypeScript that allows you to refine the type of a variable within a conditional block. Not using it effectively can lead to type errors.
    • Fix: Use type guards (e.g., `typeof`, `instanceof`, custom type guards) to narrow the type of a variable within a conditional block. This allows the TypeScript compiler to understand the type of the variable within that block and provide more accurate type checking. For example, use `if (typeof myVar === “string”) { … }` to ensure that `myVar` is treated as a string within the `if` block.
  • Poor Code Formatting: Poorly formatted code is difficult to read and understand.
    • Fix: Use a code formatter (e.g., Prettier) to automatically format your code. Follow consistent code style guidelines. Use indentation and whitespace to make your code more readable.

Key Takeaways

Building a code review system with TypeScript is a practical project that can significantly improve your skills. You’ve learned about defining data structures using interfaces and enums, creating basic API endpoints (conceptually), and building a simple front-end component. Remember that the core of this tutorial is to illustrate how to use TypeScript, and many aspects of a real-world application (like the back-end implementation, database interactions, and more sophisticated front-end features) are beyond the scope. By applying the concepts covered in this tutorial, you can create a robust and well-structured code review system. The key is to use TypeScript to its full potential, defining clear types, validating data, and writing maintainable code.

FAQ

Here are some frequently asked questions about building a code review system with TypeScript:

1. What are the main advantages of using TypeScript for this project?

TypeScript provides several advantages. It adds static typing, which helps catch errors early in the development process and improves code maintainability. It also provides better code completion and refactoring capabilities in your editor, making development faster and easier. Furthermore, TypeScript’s interfaces and types help you define and enforce the structure of your data, making your code more robust and less prone to errors.

2. What are the best practices for handling API interactions in a TypeScript project?

When interacting with an API in a TypeScript project, it’s crucial to define interfaces or types for the data you expect to receive and send. Use the `fetch` API or a library like `axios` to make the API requests. Handle potential errors using `try…catch` blocks and provide informative error messages to the user. Consider using a library like `zod` or `yup` for runtime validation to ensure data integrity. Finally, use `async/await` to handle asynchronous operations in a clean and readable way.

3. How can I improve the performance of my code review system?

Performance optimization is essential for any web application. Consider these strategies: Optimize your database queries to retrieve only the necessary data. Implement pagination for large datasets to reduce the amount of data loaded at once. Use caching mechanisms (e.g., browser caching, server-side caching) to store frequently accessed data. Optimize your front-end code by minimizing the number of API calls, using code splitting, and optimizing images. Use a content delivery network (CDN) to serve static assets.

4. How can I deploy my code review system?

The deployment process depends on your chosen technologies. For a front-end application, you can deploy it to a platform like Netlify or Vercel, which can automatically build and deploy your application from a Git repository. For the back-end, you can deploy it to a cloud platform like AWS, Google Cloud, or Azure. You’ll need to configure your web server (e.g., Nginx or Apache) and your database. You’ll also need to configure your domain name and SSL certificate. Containerization technologies like Docker can simplify the deployment process.

5. What are some advanced features I can add to my code review system?

To enhance your code review system, consider implementing these advanced features: Integrate with a version control system (e.g., Git) to automatically fetch code changes. Add support for code highlighting and syntax checking. Implement a more sophisticated notification system. Allow users to comment on specific lines of code. Integrate with CI/CD pipelines to automate the code review process. Implement a user authentication and authorization system.

Building a code review system, even a simplified version, provides a solid foundation for understanding web development principles and honing your TypeScript skills. It encourages you to think about data structures, API interactions, and user interface design. The journey of building such a system is filled with learning opportunities, and the result can be a valuable tool for collaboration and code quality.