الأسبوع ٣ · فصل ٣

نبني مشروع حقيقي بإيدينا

@repo/ui + Storybook · @repo/types (zod) · NestJS + React · shared tsconfig & eslint
🎯 الهدف: monorepo فيه Types + Design System + NestJS + React (Vite) مشتركة، كل حاجة متوصلة صح.

الهيكل النهائي

jobs-monorepo/├── apps/│   ├── web/                # React + Vite + TanStack Router/Query│   └── api/                # NestJS├── packages/│   ├── ui/                 # Design System + Storybook│   ├── types/              # zod schemas + inferred types│   ├── tsconfig/           # base.json, react.json, nest.json│   └── eslint-config/      # flat configs├── turbo.json├── pnpm-workspace.yaml└── package.json

هنستخدم دومين قريب من الشغل: Job Postings — الـ API بيستقبل وظيفة جديدة، والـ Front عنده فورم بيعمل validate بنفس القواعد بالظبط.

@repo/tsconfig: tsconfig واحد يحكم الكل

packages/tsconfig/package.json
{  "name": "@repo/tsconfig",  "version": "0.0.0",  "private": true,  "files": ["*.json"]}
packages/tsconfig/base.json
{  "compilerOptions": {    "strict": true,    "target": "ES2022",    "skipLibCheck": true,    "esModuleInterop": true,    "resolveJsonModule": true,    "isolatedModules": true,    "declaration": true,    "noUncheckedIndexedAccess": true  }}
packages/tsconfig/react.json
{  "extends": "./base.json",  "compilerOptions": {    "lib": ["ES2022", "DOM", "DOM.Iterable"],    "jsx": "react-jsx",    "module": "ESNext",    "moduleResolution": "Bundler",    "noEmit": true  }}
packages/tsconfig/nest.json
{  "extends": "./base.json",  "compilerOptions": {    "module": "CommonJS",    "moduleResolution": "Node",    "emitDecoratorMetadata": true,    "experimentalDecorators": true,    "sourceMap": true  }}

وفي أي app:

apps/web/tsconfig.json
{  "extends": "@repo/tsconfig/react.json",  "include": ["src"]}

متنساش تضيف "@repo/tsconfig": "workspace:*" في devDependencies بتاعة كل app.

@repo/eslint-config (Flat Config)

packages/eslint-config/base.js
import js from "@eslint/js";import tseslint from "typescript-eslint";export const base = [  js.configs.recommended,  ...tseslint.configs.recommended,  { ignores: ["dist/**", ".turbo/**"] },];
packages/eslint-config/react.js
import reactHooks from "eslint-plugin-react-hooks";import { base } from "./base.js";export const react = [  ...base,  {    plugins: { "react-hooks": reactHooks },    rules: reactHooks.configs.recommended.rules,  },];
apps/web/eslint.config.js
import { react } from "@repo/eslint-config/react";export default [...react];

@repo/types: zod schemas مشتركة

دي أحلى حاجة في الـ monorepo للـ fullstack: schema واحدة = validation في الـ API + validation في الفورم + TypeScript types. لو حد غيّر قاعدة، الاتنين يتغيّروا مع بعض، والـ typecheck يمسك أي مكان اتكسر.

packages/types/src/job.ts
import { z } from "zod";export const jobTypeEnum = z.enum(["full_time", "part_time", "contract"]);export const createJobSchema = z.object({  title: z.string().min(3, "العنوان قصير"),  description: z.string().min(20),  type: jobTypeEnum,  salaryMin: z.number().int().positive().optional(),  salaryMax: z.number().int().positive().optional(),  remote: z.boolean().default(false),}).refine(  (j) => !j.salaryMin || !j.salaryMax || j.salaryMin <= j.salaryMax,  { message: "الحد الأدنى أكبر من الأقصى", path: ["salaryMax"] },);export type CreateJobInput = z.infer<typeof createJobSchema>;
packages/types/src/index.ts
export * from "./job";
packages/types/package.json
{  "name": "@repo/types",  "version": "0.0.0",  "private": true,  "main": "./dist/index.js",  "module": "./dist/index.mjs",  "types": "./dist/index.d.ts",  "exports": {    ".": {      "types": "./dist/index.d.ts",      "import": "./dist/index.mjs",      "require": "./dist/index.js"    }  },  "scripts": {    "build": "tsup src/index.ts --format cjs,esm --dts --clean",    "dev": "tsup src/index.ts --format cjs,esm --dts --watch"  },  "dependencies": { "zod": "^3.23.8" },  "devDependencies": {    "@repo/tsconfig": "workspace:*",    "tsup": "^8.3.0",    "typescript": "^5.6.0"  }}

في الـ API (NestJS)

apps/api/src/common/zod-validation.pipe.ts
import { BadRequestException, PipeTransform } from "@nestjs/common";import type { ZodSchema } from "zod";export class ZodValidationPipe implements PipeTransform {  constructor(private readonly schema: ZodSchema) {}  transform(value: unknown) {    const result = this.schema.safeParse(value);    if (!result.success) {      throw new BadRequestException(result.error.flatten());    }    return result.data;  }}
apps/api/src/jobs/jobs.controller.ts
import { Body, Controller, Post } from "@nestjs/common";import { createJobSchema, type CreateJobInput } from "@repo/types";import { ZodValidationPipe } from "../common/zod-validation.pipe";import { JobsService } from "./jobs.service";@Controller("jobs")export class JobsController {  constructor(private readonly jobs: JobsService) {}  @Post()  create(@Body(new ZodValidationPipe(createJobSchema)) dto: CreateJobInput) {    return this.jobs.create(dto);  }}

في الـ Front (React + react-hook-form)

apps/web/src/features/jobs/CreateJobForm.tsx
import { useForm } from "react-hook-form";import { zodResolver } from "@hookform/resolvers/zod";import { createJobSchema, type CreateJobInput } from "@repo/types";import { Button, TextField } from "@repo/ui";import { useCreateJob } from "./useCreateJob"; // TanStack Query mutationexport function CreateJobForm() {  const form = useForm<CreateJobInput>({ resolver: zodResolver(createJobSchema) });  const createJob = useCreateJob();  return (    <form onSubmit={form.handleSubmit((data) => createJob.mutate(data))}>      <TextField label="Title" {...form.register("title")}        error={form.formState.errors.title?.message} />      <Button type="submit" loading={createJob.isPending}>Publish</Button>    </form>  );}

@repo/ui + Storybook

packages/ui/package.json
{  "name": "@repo/ui",  "version": "0.0.0",  "private": true,  "type": "module",  "exports": {    ".": "./src/index.ts",    "./styles.css": "./src/styles.css"  },  "scripts": {    "storybook": "storybook dev -p 6006",    "build-storybook": "storybook build",    "lint": "eslint .",    "typecheck": "tsc --noEmit"  },  "peerDependencies": { "react": "^18 || ^19", "react-dom": "^18 || ^19" },  "devDependencies": {    "@repo/tsconfig": "workspace:*",    "@repo/eslint-config": "workspace:*"  }}

هنا اخترنا الطريقة التانية: Just-in-Time package — الـ exports بيشاور على .ts مباشرة من غير build. ده شغال تمام لأن المستهلك الوحيد هو web (Vite) وهو اللي بيعمل transpile.

Just-in-Time (src مباشرة)Compiled (dist)
Setupأبسط حاجةمحتاج tsup/vite lib
HMRفوري 🔥محتاج watch
الكاش في Turboمفيش build يتكيّشيتكيّش
ينفع مع NestJS/Node؟❌✅
استخدامه هناuitypes
cd packages/uipnpm dlx storybook@latest init   # اختار React + Vitepnpm storybook
packages/ui/src/Button/Button.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";import { Button } from "./Button";const meta: Meta<typeof Button> = {  title: "Actions/Button",  component: Button,  args: { children: "Publish job" },};export default meta;export const Primary: StoryObj<typeof Button> = { args: { variant: "primary" } };export const Loading: StoryObj<typeof Button> = { args: { loading: true } };

وضيف في turbo.json:

turbo.json (جزء)
"build-storybook": {  "dependsOn": ["^build"],  "outputs": ["storybook-static/**"]}

الربط النهائي

package.json (root)
{  "name": "jobs-monorepo",  "private": true,  "packageManager": "pnpm@9.12.0",  "scripts": {    "dev": "turbo dev",    "build": "turbo build",    "lint": "turbo lint",    "typecheck": "turbo typecheck",    "test": "turbo test"  },  "devDependencies": { "turbo": "^2.1.0", "typescript": "^5.6.0" }}
pnpm installpnpm dev --filter=web...      # web + ui + types (dev watch)pnpm dev --filter=api...      # api + typespnpm turbo typecheck lint     # كله مرة واحدة

Checklist الأسبوع التالت

🧠 اختبر نفسك
١) NestJS بيضرب بـ SyntaxError: Unexpected token 'export' لما يستورد @repo/types. ليه؟
الـ package بتشاور على ESM أو .ts و Nest شغال CJS. ابنيها بـ tsup cjs+esm وظبّط exports.
٢) "Invalid hook call" ظهر بعد ما ضفت @repo/ui. أول حاجة تبص عليها؟
React موجودة في dependencies بتاعة ui بدل peerDependencies → نسختين React.
٣) فين تحط typescript-eslint كـ dependency؟
في @repo/eslint-config نفسها، مش في كل app.