Syntax Project


https://www.typescriptlang.org/docs/handbook/intro.html
https://www.typescriptlang.org/docs/handbook/2/everyday-types.html
https://www.typescriptlang.org/docs/handbook/2/narrowing.html
https://www.typescriptlang.org/docs/handbook/2/classes.html
https://www.prisma.io/docs/orm
https://www.prisma.io/docs/orm/prisma-client
https://www.prisma.io/docs/orm/prisma-client/queries/crud

1. Goal#

目标:
    用一个尽可能小的 order project 覆盖 TypeScript 常用语法
    使用 Prisma 作为 TypeScript 常见 ORM 示例
    不是为了业务完整
    不是为了框架完整
    只是为了把语法放进真实代码位置

you will see:
    type / interface
    union / discriminated union
    enum-like literal union
    optional / readonly
    generic
    utility types
    class
    implements
    async / await
    unknown
    type narrowing
    never
    Record / tuple / satisfies
    Prisma schema
    Prisma Client
    repository with ORM

2. Project Structure#

ts-syntax-project
├── package.json
├── tsconfig.json
├── .env
├── prisma
│   └── schema.prisma
└── src
    ├── main.ts
    ├── domain.ts
    ├── prisma.ts
    ├── repository.ts
    ├── service.ts
    └── result.ts

3. package.json#

{
  "name": "ts-syntax-project",
  "version": "1.0.0",
  "type": "module",
  "private": true,
  "scripts": {
    "dev": "tsx src/main.ts",
    "typecheck": "tsc --noEmit",
    "prisma:generate": "prisma generate",
    "prisma:migrate": "prisma migrate dev --name init"
  },
  "dependencies": {
    "@prisma/client": "latest"
  },
  "devDependencies": {
    "@types/node": "latest",
    "prisma": "latest",
    "tsx": "latest",
    "typescript": "latest"
  }
}

4. tsconfig.json#

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*.ts"]
}

5. .env#

DATABASE_URL="file:./dev.db"
why:
    SQLite keeps this syntax demo self-contained
    Prisma reads DATABASE_URL from .env

6. prisma/schema.prisma#

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

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

model Order {
  orderId   String      @id @default(uuid())
  userId    String
  status    String
  createdAt DateTime    @default(now())
  paidAt    DateTime?
  items     OrderItem[]
}

model OrderItem {
  id       Int    @id @default(autoincrement())
  orderId  String
  sku      String
  name     String
  quantity Int
  amount   Float
  currency String
  order    Order  @relation(fields: [orderId], references: [orderId], onDelete: Cascade)
}
why:
    Order / OrderItem 展示 relation
    SQLite 让本地运行不依赖外部数据库
    Prisma Client 会基于 schema 生成类型安全的 query API

7. src/result.ts#

// generic: Result<T, E> 可以复用给不同 value/error 类型,避免每个 service 重复定义返回结构
export type Result<T, E extends string = string> =
  // discriminated union: ok=true/false 让 TypeScript 自动 narrow value 或 error
  | { ok: true; value: T }
  | { ok: false; error: E };

// never: 用于 switch exhaustive check,新增 union member 时可以让编译器提醒
export function assertNever(value: never): never {
  throw new Error(`unexpected value: ${String(value)}`);
}

8. src/domain.ts#

// literal union: 比 enum 更轻,适合固定业务状态
export type OrderStatus = "PENDING" | "PAID" | "CANCELLED";

export type Currency = "USD" | "HKD" | "CNY";

// Readonly: 金额对象创建后不应该被随意修改
export type Money = Readonly<{
  amount: number;
  currency: Currency;
}>;

export type OrderItemInput = {
  // readonly: sku 是 item identity,不希望业务逻辑中被改写
  readonly sku: string;
  name: string;
  quantity: number;
  price: Money;
  // optional property: tags 不是必填字段
  tags?: string[];
};

export type Order = {
  readonly orderId: string;
  userId: string;
  status: OrderStatus;
  items: OrderItemInput[];
  createdAt: Date;
  paidAt?: Date;
};

// Pick + intersection: 复用 Order 的 userId 类型,同时扩展创建订单需要的 items
export type CreateOrderInput = Pick<Order, "userId"> & {
  items: OrderItemInput[];
};

// Pick: API list 返回摘要,不暴露完整对象
export type OrderSummary = Pick<Order, "orderId" | "userId" | "status"> & {
  totalAmount: number;
};

// Partial: patch 更新只允许改一部分字段
export type OrderPatch = Partial<Pick<Order, "status" | "paidAt">>;

// satisfies + Record: 保证每个 OrderStatus 都有 label,又保留 object literal 的精确类型
export const statusLabel = {
  PENDING: "Waiting Payment",
  PAID: "Paid",
  CANCELLED: "Cancelled"
} satisfies Record<OrderStatus, string>;

// discriminated union: audit event 通过 type 字段区分不同 payload
export type AuditEvent =
  | { type: "ORDER_CREATED"; orderId: string; at: Date }
  | { type: "ORDER_PAID"; orderId: string; transactionId: string; at: Date }
  | { type: "ORDER_CANCELLED"; orderId: string; reason: string; at: Date };

// tuple: pagination 的第一个值固定是 limit,第二个值可选是 cursor
export type Pagination = readonly [limit: number, cursor?: string];

9. src/prisma.ts#

import { PrismaClient } from "@prisma/client";

// singleton: 一个进程复用一个 PrismaClient,避免频繁创建连接
export const prisma = new PrismaClient();

10. src/repository.ts#

import type { PrismaClient } from "@prisma/client";
import type { Order, OrderItemInput } from "./domain.js";

// interface: service 依赖抽象,方便测试时替换为 fake repository
export interface OrderRepository {
  findById(orderId: string): Promise<Order | null>;
  save(order: Order): Promise<void>;
  list(): Promise<Order[]>;
}

export class PrismaOrderRepository implements OrderRepository {
  // constructor parameter property: 简洁声明并初始化 private readonly 字段
  constructor(private readonly db: PrismaClient) {}

  async findById(orderId: string): Promise<Order | null> {
    const record = await this.db.order.findUnique({
      where: { orderId },
      // ORM relation: include items,一次查出 order 和 order_items
      include: { items: true }
    });

    // nullish check: Prisma 查不到时返回 null
    if (record === null) {
      return null;
    }

    return {
      orderId: record.orderId,
      userId: record.userId,
      // type assertion: DB 存 string,这里收窄成业务 literal union
      status: record.status as Order["status"],
      createdAt: record.createdAt,
      // conditional spread: exactOptionalPropertyTypes 下不要写 paidAt: undefined
      ...(record.paidAt === null ? {} : { paidAt: record.paidAt }),
      items: record.items.map((item) => ({
        sku: item.sku,
        name: item.name,
        quantity: item.quantity,
        price: {
          amount: item.amount,
          currency: item.currency as OrderItemInput["price"]["currency"]
        }
      }))
    };
  }

  async save(order: Order): Promise<void> {
    await this.db.order.upsert({
      where: { orderId: order.orderId },
      update: {
        status: order.status,
        paidAt: order.paidAt,
        // nested write: 简化 demo,先删旧 items 再重建
        items: {
          deleteMany: {},
          create: order.items.map((item) => ({
            sku: item.sku,
            name: item.name,
            quantity: item.quantity,
            amount: item.price.amount,
            currency: item.price.currency
          }))
        }
      },
      create: {
        orderId: order.orderId,
        userId: order.userId,
        status: order.status,
        createdAt: order.createdAt,
        paidAt: order.paidAt,
        items: {
          create: order.items.map((item) => ({
            sku: item.sku,
            name: item.name,
            quantity: item.quantity,
            amount: item.price.amount,
            currency: item.price.currency
          }))
        }
      }
    });
  }

  async list(): Promise<Order[]> {
    const records = await this.db.order.findMany({
      include: { items: true },
      orderBy: { createdAt: "desc" }
    });

    // Promise.all: map async function 时等待所有转换完成
    return Promise.all(records.map((record) => this.findById(record.orderId)))
      .then((orders) => orders.filter((order): order is Order => order !== null));
  }
}

11. src/service.ts#

import { randomUUID } from "node:crypto";
import type { AuditEvent, CreateOrderInput, Order, OrderSummary, Pagination } from "./domain.js";
import { assertNever, type Result } from "./result.js";
import type { OrderRepository } from "./repository.js";

type PayOrderError = "ORDER_NOT_FOUND" | "INVALID_STATUS";

export class OrderService {
  constructor(private readonly repository: OrderRepository) {}

  async create(input: CreateOrderInput): Promise<Order> {
    const order: Order = {
      orderId: randomUUID(),
      userId: input.userId,
      status: "PENDING",
      items: input.items,
      createdAt: new Date()
    };

    await this.repository.save(order);
    this.handleAudit({ type: "ORDER_CREATED", orderId: order.orderId, at: new Date() });

    return order;
  }

  async pay(orderId: string): Promise<Result<Order, PayOrderError>> {
    const order = await this.repository.findById(orderId);

    if (order === null) {
      return { ok: false, error: "ORDER_NOT_FOUND" };
    }

    if (order.status !== "PENDING") {
      return { ok: false, error: "INVALID_STATUS" };
    }

    // object spread: 基于旧对象复制并覆盖 status / paidAt
    const paidOrder: Order = {
      ...order,
      status: "PAID",
      paidAt: new Date()
    };

    await this.repository.save(paidOrder);

    this.handleAudit({
      type: "ORDER_PAID",
      orderId,
      transactionId: randomUUID(),
      at: new Date()
    });

    return { ok: true, value: paidOrder };
  }

  async summarize(pagination: Pagination = [20]): Promise<OrderSummary[]> {
    // destructuring + default parameter: 调用方不传 pagination 时默认 limit=20
    const [limit] = pagination;
    const orders = await this.repository.list();

    return orders.slice(0, limit).map((order) => ({
      orderId: order.orderId,
      userId: order.userId,
      status: order.status,
      // reduce: 把多个 item 金额聚合成订单总额
      totalAmount: order.items.reduce(
        (sum, item) => sum + item.quantity * item.price.amount,
        0
      )
    }));
  }

  parseCreateInput(value: unknown): CreateOrderInput {
    // unknown + type guard: 外部输入必须先校验再进入业务逻辑
    if (!isCreateOrderInput(value)) {
      throw new Error("invalid create order input");
    }

    return value;
  }

  private handleAudit(event: AuditEvent): void {
    switch (event.type) {
      case "ORDER_CREATED":
        console.log(`created ${event.orderId}`);
        return;
      case "ORDER_PAID":
        console.log(`paid ${event.orderId} by ${event.transactionId}`);
        return;
      case "ORDER_CANCELLED":
        console.log(`cancelled ${event.orderId}: ${event.reason}`);
        return;
      default:
        // never: 如果 AuditEvent 新增类型但 switch 没处理,编译期会暴露问题
        assertNever(event);
    }
  }
}

function isCreateOrderInput(value: unknown): value is CreateOrderInput {
  if (typeof value !== "object" || value === null) {
    return false;
  }

  // type assertion: 经过 object/null 检查后,用临时结构读取字段
  const candidate = value as { userId?: unknown; items?: unknown };

  return typeof candidate.userId === "string" && Array.isArray(candidate.items);
}

12. src/main.ts#

import { prisma } from "./prisma.js";
import { PrismaOrderRepository } from "./repository.js";
import { OrderService } from "./service.js";

const repository = new PrismaOrderRepository(prisma);
const service = new OrderService(repository);

const input = service.parseCreateInput({
  userId: "u-1001",
  items: [
    {
      sku: "sku-1001",
      name: "Keyboard",
      quantity: 1,
      price: {
        amount: 99.9,
        currency: "USD"
      },
      tags: ["hardware"]
    }
  ]
});

const order = await service.create(input);
const paid = await service.pay(order.orderId);

// discriminated union narrowing: paid.ok 为 true 时才能访问 paid.value
if (paid.ok) {
  console.log("paid order", paid.value);
} else {
  console.log("pay failed", paid.error);
}

console.log(await service.summarize([10]));

await prisma.$disconnect();

13. Run#

npm install
npm run prisma:migrate
npm run typecheck
npm run dev
expected:
    created <order_id>
    paid <order_id> by <transaction_id>
    paid order {...}
    [{ orderId, userId, status, totalAmount }]

14. Reading Order#

read files in this order:
    1. prisma/schema.prisma
    2. domain.ts
    3. result.ts
    4. prisma.ts
    5. repository.ts
    6. service.ts
    7. main.ts

理解重点:
    Prisma schema defines database model
    Prisma Client provides type-safe ORM API
    domain.ts defines business shape
    repository maps ORM records to domain objects
    service owns business logic
    main wires everything together