iT邦幫忙

2025 iThome 鐵人賽

DAY 27
0
自我挑戰組

《轉職學習日記:JavaScript × Node.js × TypeScript × Docker × AWS ECS》系列 第 27

Day27 - 持續成長學習藍圖 - TypeScript(小作品)

  • 分享至 

  • xImage
  •  

從 Day 19 開始,我陸續學了 TypeScript 的型別、介面、Enum、泛型、DTO、Prisma ORM、錯誤處理……
今天要把這些全部整合起來,打造一個「真正能上線」的小作品:
👉 Todo API(TypeScript 版)


1️⃣ 專案目標

  • 使用 Express + TypeScript + Prisma
  • 所有請求都通過 DTO 驗證
  • 使用 Service / Controller 架構
  • 錯誤統一由 Middleware 處理
  • 輸出統一 JSON 格式

這次不只是寫功能,而是讓整體架構「有設計感」。


2️⃣ 專案結構

最後成品的目錄長這樣 👇

src/
 ├── controllers/
 │   └── todo.controller.ts
 ├── services/
 │   └── todo.service.ts
 ├── dto/
 │   ├── create-todo.dto.ts
 │   └── update-todo.dto.ts
 ├── middleware/
 │   ├── error-handler.ts
 │   └── validate-dto.ts
 ├── prisma/
 │   └── client.ts
 ├── routes/
 │   └── todo.routes.ts
 ├── utils/
 │   └── http-exception.ts
 └── index.ts

乾淨明確,每一層都有自己的職責。


3️⃣ Prisma schema

prisma/schema.prisma

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

model Todo {
  id        Int      @id @default(autoincrement())
  task      String
  done      Boolean  @default(false)
  note      String?
  createdAt DateTime @default(now())
}

然後執行初始化:

npx prisma migrate dev --name init
npx prisma generate

4️⃣ DTO 定義

src/dto/create-todo.dto.ts

import { IsString, IsOptional, Length } from "class-validator";

export class CreateTodoDto {
  @IsString()
  @Length(1, 50, { message: "task 長度必須在 1~50 之間" })
  task: string;

  @IsOptional()
  @IsString()
  note?: string;
}

src/dto/update-todo.dto.ts

import { IsOptional, IsString, IsBoolean } from "class-validator";

export class UpdateTodoDto {
  @IsOptional()
  @IsString()
  task?: string;

  @IsOptional()
  @IsBoolean()
  done?: boolean;

  @IsOptional()
  @IsString()
  note?: string;
}

5️⃣ 錯誤處理與輔助工具

src/utils/http-exception.ts

export class HttpException extends Error {
  status: number;
  message: string;

  constructor(status: number, message: string) {
    super(message);
    this.status = status;
  }
}

src/middleware/error-handler.ts

import { Request, Response, NextFunction } from "express";
import { HttpException } from "../utils/http-exception";

export function errorHandler(err: unknown, req: Request, res: Response, next: NextFunction) {
  console.error("❌ 錯誤:", err);
  if (err instanceof HttpException) {
    return res.status(err.status).json({ error: err.message });
  }
  return res.status(500).json({ error: "伺服器內部錯誤" });
}

src/middleware/validate-dto.ts

import { plainToInstance } from "class-transformer";
import { validate } from "class-validator";
import { Request, Response, NextFunction } from "express";

export function validateDto(dtoClass: any) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const instance = plainToInstance(dtoClass, req.body);
    const errors = await validate(instance);
    if (errors.length > 0) {
      const messages = errors.map(err => Object.values(err.constraints || {})).flat();
      return res.status(400).json({ errors: messages });
    }
    next();
  };
}

6️⃣ Service 與 Controller

src/services/todo.service.ts

import prisma from "../prisma/client";
import { HttpException } from "../utils/http-exception";

export class TodoService {
  async findAll() {
    return prisma.todo.findMany();
  }

  async create(task: string, note?: string) {
    return prisma.todo.create({ data: { task, note } });
  }

  async update(id: number, data: any) {
    const todo = await prisma.todo.findUnique({ where: { id } });
    if (!todo) throw new HttpException(404, "找不到這筆 Todo");
    return prisma.todo.update({ where: { id }, data });
  }

  async delete(id: number) {
    const todo = await prisma.todo.findUnique({ where: { id } });
    if (!todo) throw new HttpException(404, "找不到這筆 Todo");
    await prisma.todo.delete({ where: { id } });
    return { message: "Todo 已刪除" };
  }
}

src/controllers/todo.controller.ts

import { Request, Response, NextFunction } from "express";
import { TodoService } from "../services/todo.service";

const todoService = new TodoService();

export class TodoController {
  async getAll(req: Request, res: Response, next: NextFunction) {
    try {
      const todos = await todoService.findAll();
      res.json(todos);
    } catch (err) {
      next(err);
    }
  }

  async create(req: Request, res: Response, next: NextFunction) {
    try {
      const { task, note } = req.body;
      const todo = await todoService.create(task, note);
      res.status(201).json(todo);
    } catch (err) {
      next(err);
    }
  }

  async update(req: Request, res: Response, next: NextFunction) {
    try {
      const id = Number(req.params.id);
      const todo = await todoService.update(id, req.body);
      res.json(todo);
    } catch (err) {
      next(err);
    }
  }

  async delete(req: Request, res: Response, next: NextFunction) {
    try {
      const id = Number(req.params.id);
      const result = await todoService.delete(id);
      res.json(result);
    } catch (err) {
      next(err);
    }
  }
}

7️⃣ 路由與主程式

src/routes/todo.routes.ts

import { Router } from "express";
import { validateDto } from "../middleware/validate-dto";
import { CreateTodoDto } from "../dto/create-todo.dto";
import { UpdateTodoDto } from "../dto/update-todo.dto";
import { TodoController } from "../controllers/todo.controller";

const router = Router();
const controller = new TodoController();

router.get("/", controller.getAll.bind(controller));
router.post("/", validateDto(CreateTodoDto), controller.create.bind(controller));
router.put("/:id", validateDto(UpdateTodoDto), controller.update.bind(controller));
router.delete("/:id", controller.delete.bind(controller));

export default router;

src/index.ts

import "reflect-metadata";
import express from "express";
import todoRoutes from "./routes/todo.routes";
import { errorHandler } from "./middleware/error-handler";

const app = express();
app.use(express.json());
app.use("/todos", todoRoutes);
app.use(errorHandler); // 全域錯誤處理放最後

app.listen(3000, () => console.log("🚀 TS Todo API running on http://localhost:3000"));

8️⃣ 測試 API

✅ 新增 Todo

curl -X POST http://localhost:3000/todos \
  -H "Content-Type: application/json" \
  -d '{"task":"Day 27 收尾測試","note":"TypeScript 完整版"}'

✅ 查詢 Todo

curl http://localhost:3000/todos

✅ 更新 Todo

curl -X PUT http://localhost:3000/todos/1 \
  -H "Content-Type: application/json" \
  -d '{"done":true}'

✅ 刪除 Todo

curl -X DELETE http://localhost:3000/todos/1

9️⃣ README.md

# TS Todo API

一個以 TypeScript + Express + Prisma 建構的 Todo API 小作品。

## 功能
- 建立、查詢、更新、刪除 Todo
- DTO 驗證輸入資料
- 全域錯誤處理統一 JSON 格式
- Prisma ORM 操作資料庫(SQLite)

## 使用方式
```bash
npm install
npx prisma migrate dev --name init
npm run dev

伺服器啟動後:

http://localhost:3000/todos

🎯 學習心得 / 今日收穫

Day 27 是整個 TypeScript 後端階段的完結篇。
回頭看這幾天,從學型別、泛型、DTO、再到 ORM、錯誤處理、架構分層,
我終於把這些「分散的概念」組成一個能實際運作的專案。

✨ 今天的成果代表:

  • 我可以用 TypeScript 開發安全的 API
  • 我會用 DTO + Middleware 做資料驗證
  • 我懂得讓錯誤集中管理
  • 我有能力設計一個有清楚層次的專案架構

這幾天就像在蓋一棟房子——
前面在學地基和水電,今天終於可以住進去了 🏠


上一篇
Day26 - 持續成長學習藍圖 - TypeScript(錯誤處理與結構優化)
下一篇
Day28 - 持續成長學習藍圖 - Docker
系列文
《轉職學習日記:JavaScript × Node.js × TypeScript × Docker × AWS ECS》29
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言