توقف عن فقدان الأخطاء في ثقوب try-catch السوداء. تمنحك Effect-TS أخطاءً مُنمَّطة وأنابيب قابلة للتركيب وحقن تبعيات مدمجاً — كل ذلك مع البقاء في TypeScript بالكامل. في هذا الدليل، ستبني خدمة إدارة مستخدمين جاهزة للإنتاج مع أخطاء مُتتبَّعة وإعادة محاولات وتبعيات قابلة للاختبار.
ما ستتعلمه
بنهاية هذا الدليل، ستتمكن من:
- فهم ما هي Effect-TS ولماذا تكتسب انتشاراً واسعاً في 2026
- نمذجة أخطاء مُنمَّطة يتتبعها المترجم عبر تطبيقك بالكامل
- بناء أنابيب قابلة للتركيب باستخدام
Effect.pipeوالمولّدات - تنفيذ حقن التبعيات باستخدام نمط Service/Layer
- التعامل مع التزامن باستخدام بدائيات التزامن المُهيكل
- إضافة إعادة المحاولات والمُهَل الزمنية والجدولة بدون كود إضافي
- كتابة خدمات قابلة للاختبار عن طريق تبديل الطبقات
- بناء واجهة برمجة تطبيقات لإدارة المستخدمين من الصفر
المتطلبات الأساسية
قبل البدء، تأكد من توفر:
- Node.js 20+ مثبت (
node --version) - خبرة في TypeScript 5.4+ (الأنواع العامة، استنتاج الأنواع، الاتحادات المُميَّزة)
- إلمام بأنماط async/await
- فهم أساسي لمفاهيم حقن التبعيات
- محرر أكواد يدعم TypeScript (يُنصح بـ VS Code)
لماذا Effect-TS؟
معالجة الأخطاء التقليدية في TypeScript بها مشكلة جوهرية: try-catch تمحو معلومات الأنواع. عندما تلتقط خطأً، تُنمِّطه TypeScript كـ unknown — لا تعرف ما الخطأ في وقت التجميع.
// النهج التقليدي — الأخطاء غير مرئية لنظام الأنواع
async function getUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error("Failed to fetch user");
return response.json();
}
// ما الأخطاء التي يمكن أن تُرمى؟ توقيع النوع لا يخبرك.
// قد تكون: NetworkError, NotFoundError, ParseError, TimeoutError...تحل Effect-TS هذا بتشفير الأخطاء في توقيع النوع:
import { Effect } from "effect";
// Effect<User, NetworkError | NotFoundError, UserService>
// نوع النجاح ↑ أنواع الأخطاء ↑ التبعيات ↑كل دالة تعلن بالضبط ما يمكن أن يحدث خطأ وما التبعيات المطلوبة. المترجم يفرض ذلك في كل نقطة استدعاء.
الخطوة 1: إعداد المشروع
أنشئ مشروعاً جديداً وثبّت Effect:
mkdir effect-user-service && cd effect-user-service
npm init -y
npm install effect @effect/platform @effect/schema
npm install -D typescript tsx @types/nodeهيّئ TypeScript بإعدادات صارمة:
npx tsc --initحدّث ملف tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"exactOptionalPropertyTypes": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}أنشئ هيكل المشروع:
mkdir -p src/{errors,services,layers,models}أضف أمر التشغيل في package.json:
{
"scripts": {
"dev": "tsx watch src/main.ts",
"start": "tsx src/main.ts"
}
}الخطوة 2: فهم نوع Effect
جوهر Effect-TS هو نوع Effect. فكّر فيه كـ Promise مُعزَّز يتتبع ثلاثة أشياء:
// Effect<Success, Error, Requirements>
//
// Success — القيمة المُنتَجة عند النجاح
// Error — أنواع الأخطاء المحتملة (اتحاد)
// Requirements — الخدمات/التبعيات المطلوبة للتشغيلأنشئ ملف src/basics.ts لاستكشاف الأساسيات:
import { Effect, Console } from "effect";
// Effect بسيط ينجح بسلسلة نصية
const greeting = Effect.succeed("Hello, Effect!");
// Effect بسيط يفشل بخطأ
const failure = Effect.fail("Something went wrong");
// Effects كسولة — لا شيء يعمل حتى تنفّذها
// هذا مجرد وصف لعملية حسابية
// تحويل القيم باستخدام map
const loudGreeting = greeting.pipe(
Effect.map((msg) => msg.toUpperCase())
);
// ربط effects باستخدام flatMap
const program = Effect.flatMap(greeting, (msg) => {
return Console.log(msg);
});
// استخدم المولّدات لأسلوب حتمي (مُوصى به)
const generatorProgram = Effect.gen(function* () {
const msg = yield* greeting;
yield* Console.log(msg);
yield* Console.log("Effect-TS is awesome!");
});
// شغّل البرنامج
Effect.runPromise(generatorProgram).then(() => {
console.log("Done!");
});شغّله:
npx tsx src/basics.tsسترى:
Hello, Effect!
Effect-TS is awesome!
Done!
المولّدات مقابل pipe: كلا الأسلوبين متكافئان تماماً. المولّدات (Effect.gen) تعطيك كوداً يبدو حتمياً مع yield*، بينما pipe يعطيك أسلوب تركيب وظيفي. معظم الفرق تفضل المولّدات لسهولة القراءة. استخدم ما يناسب فريقك.
الخطوة 3: تعريف الأخطاء المُنمَّطة
تبدأ القوة الحقيقية لـ Effect مع الأخطاء المُنمَّطة. بدلاً من رمي كائنات Error عامة، تُعرّف فئات أخطاء محددة يتتبعها نظام الأنواع.
أنشئ ملف src/errors/user-errors.ts:
import { Data } from "effect";
// Data.TaggedError ينشئ عضو اتحاد مُوسَم بحقل _tag
// هذا يُمكّن المطابقة الشاملة للأنماط
export class UserNotFoundError extends Data.TaggedError(
"UserNotFoundError"
)<{
readonly userId: string;
}> {}
export class UserAlreadyExistsError extends Data.TaggedError(
"UserAlreadyExistsError"
)<{
readonly email: string;
}> {}
export class ValidationError extends Data.TaggedError(
"ValidationError"
)<{
readonly field: string;
readonly message: string;
}> {}
export class DatabaseError extends Data.TaggedError(
"DatabaseError"
)<{
readonly operation: string;
readonly cause: unknown;
}> {}
export class NetworkError extends Data.TaggedError(
"NetworkError"
)<{
readonly url: string;
readonly statusCode: number;
}> {}الآن استخدمها في دالة:
import { Effect } from "effect";
import { UserNotFoundError, ValidationError } from "./errors/user-errors.js";
// نوع الإرجاع يعلن صراحةً: يمكن أن يفشل بـ
// UserNotFoundError أو ValidationError
function getUser(
id: string
): Effect.Effect<User, UserNotFoundError | ValidationError> {
return Effect.gen(function* () {
if (!id || id.length === 0) {
return yield* new ValidationError({
field: "id",
message: "User ID cannot be empty",
});
}
const user = yield* lookupUser(id);
if (!user) {
return yield* new UserNotFoundError({ userId: id });
}
return user;
});
}المترجم الآن يعرف بالضبط ما الأخطاء التي يمكن أن تُنتجها getUser. إذا استدعيت getUser ونسيت معالجة UserNotFoundError، سيخبرك نظام الأنواع.
الخطوة 4: بناء نموذج المستخدم
أنشئ ملف src/models/user.ts باستخدام @effect/schema للتحقق في وقت التشغيل:
import { Schema } from "@effect/schema";
// تعريف مخطط المستخدم — يتحقق في وقت التشغيل، يستنتج الأنواع في وقت التجميع
export const UserSchema = Schema.Struct({
id: Schema.String.pipe(Schema.nonEmptyString()),
email: Schema.String.pipe(Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)),
name: Schema.String.pipe(Schema.nonEmptyString(), Schema.maxLength(100)),
role: Schema.Literal("admin", "user", "moderator"),
createdAt: Schema.Date,
updatedAt: Schema.Date,
});
// استنتاج نوع TypeScript من المخطط
export type User = typeof UserSchema.Type;
// مخطط لإنشاء مستخدم جديد (بدون id، التواريخ تُولَّد تلقائياً)
export const CreateUserSchema = Schema.Struct({
email: Schema.String.pipe(Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)),
name: Schema.String.pipe(Schema.nonEmptyString(), Schema.maxLength(100)),
role: Schema.optional(Schema.Literal("admin", "user", "moderator"), {
default: () => "user" as const,
}),
});
export type CreateUser = typeof CreateUserSchema.Type;
// مخطط لتحديث مستخدم موجود
export const UpdateUserSchema = Schema.Struct({
email: Schema.optional(
Schema.String.pipe(Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/))
),
name: Schema.optional(
Schema.String.pipe(Schema.nonEmptyString(), Schema.maxLength(100))
),
role: Schema.optional(Schema.Literal("admin", "user", "moderator")),
});
export type UpdateUser = typeof UpdateUserSchema.Type;الخطوة 5: إنشاء الخدمات مع حقن التبعيات
تملك Effect-TS نظام حقن تبعيات مدمجاً باستخدام الخدمات والطبقات. الخدمات تُعرّف القدرات المطلوبة؛ الطبقات توفر التنفيذ.
أنشئ ملف src/services/user-repository.ts:
import { Effect, Context, Layer } from "effect";
import type { User, CreateUser, UpdateUser } from "../models/user.js";
import {
UserNotFoundError,
UserAlreadyExistsError,
DatabaseError,
} from "../errors/user-errors.js";
// 1. تعريف واجهة الخدمة باستخدام Context.Tag
export class UserRepository extends Context.Tag("UserRepository")<
UserRepository,
{
readonly findById: (
id: string
) => Effect.Effect<User, UserNotFoundError | DatabaseError>;
readonly findByEmail: (
email: string
) => Effect.Effect<User | null, DatabaseError>;
readonly create: (
data: CreateUser
) => Effect.Effect<User, UserAlreadyExistsError | DatabaseError>;
readonly update: (
id: string,
data: UpdateUser
) => Effect.Effect<User, UserNotFoundError | DatabaseError>;
readonly delete: (
id: string
) => Effect.Effect<void, UserNotFoundError | DatabaseError>;
readonly list: () => Effect.Effect<ReadonlyArray<User>, DatabaseError>;
}
>() {}ثم أنشئ تنفيذاً في الذاكرة في src/layers/in-memory-user-repository.ts:
import { Effect, Layer, Ref } from "effect";
import { UserRepository } from "../services/user-repository.js";
import type { User, CreateUser, UpdateUser } from "../models/user.js";
import {
UserNotFoundError,
UserAlreadyExistsError,
DatabaseError,
} from "../errors/user-errors.js";
export const InMemoryUserRepository = Layer.effect(
UserRepository,
Effect.gen(function* () {
const store = yield* Ref.make<Map<string, User>>(new Map());
let counter = 0;
return {
findById: (id: string) =>
Effect.gen(function* () {
const users = yield* Ref.get(store);
const user = users.get(id);
if (!user) {
return yield* new UserNotFoundError({ userId: id });
}
return user;
}),
findByEmail: (email: string) =>
Effect.gen(function* () {
const users = yield* Ref.get(store);
for (const user of users.values()) {
if (user.email === email) return user;
}
return null;
}),
create: (data: CreateUser) =>
Effect.gen(function* () {
const existing = yield* Effect.flatMap(
Ref.get(store),
(users) => {
for (const user of users.values()) {
if (user.email === data.email) {
return Effect.succeed(user);
}
}
return Effect.succeed(null);
}
);
if (existing) {
return yield* new UserAlreadyExistsError({
email: data.email,
});
}
counter++;
const now = new Date();
const user: User = {
id: `user_${counter}`,
email: data.email,
name: data.name,
role: data.role ?? "user",
createdAt: now,
updatedAt: now,
};
yield* Ref.update(store, (map) => {
const next = new Map(map);
next.set(user.id, user);
return next;
});
return user;
}),
update: (id: string, data: UpdateUser) =>
Effect.gen(function* () {
const users = yield* Ref.get(store);
const existing = users.get(id);
if (!existing) {
return yield* new UserNotFoundError({ userId: id });
}
const updated: User = {
...existing,
...Object.fromEntries(
Object.entries(data).filter(([, v]) => v !== undefined)
),
updatedAt: new Date(),
};
yield* Ref.update(store, (map) => {
const next = new Map(map);
next.set(id, updated);
return next;
});
return updated;
}),
delete: (id: string) =>
Effect.gen(function* () {
const users = yield* Ref.get(store);
if (!users.has(id)) {
return yield* new UserNotFoundError({ userId: id });
}
yield* Ref.update(store, (map) => {
const next = new Map(map);
next.delete(id);
return next;
});
}),
list: () =>
Effect.gen(function* () {
const users = yield* Ref.get(store);
return Array.from(users.values());
}),
};
})
);الخطوة 6: بناء خدمة المستخدم
الآن أنشئ خدمة عالية المستوى تضيف منطق الأعمال فوق المستودع. أنشئ src/services/user-service.ts:
import { Effect, Context, Layer } from "effect";
import { Schema } from "@effect/schema";
import { UserRepository } from "./user-repository.js";
import {
CreateUserSchema,
UpdateUserSchema,
type User,
} from "../models/user.js";
import {
UserNotFoundError,
UserAlreadyExistsError,
ValidationError,
DatabaseError,
} from "../errors/user-errors.js";
export class UserService extends Context.Tag("UserService")<
UserService,
{
readonly getUser: (
id: string
) => Effect.Effect<User, UserNotFoundError | DatabaseError>;
readonly createUser: (
input: unknown
) => Effect.Effect<
User,
ValidationError | UserAlreadyExistsError | DatabaseError
>;
readonly updateUser: (
id: string,
input: unknown
) => Effect.Effect<
User,
ValidationError | UserNotFoundError | DatabaseError
>;
readonly deleteUser: (
id: string
) => Effect.Effect<void, UserNotFoundError | DatabaseError>;
readonly listUsers: () => Effect.Effect<
ReadonlyArray<User>,
DatabaseError
>;
}
>() {}
export const UserServiceLive = Layer.effect(
UserService,
Effect.gen(function* () {
const repo = yield* UserRepository;
return {
getUser: (id: string) => repo.findById(id),
createUser: (input: unknown) =>
Effect.gen(function* () {
const parsed = yield* Schema.decodeUnknown(
CreateUserSchema
)(input).pipe(
Effect.mapError(
(error) =>
new ValidationError({
field: "input",
message: `Invalid input: ${error.message}`,
})
)
);
const existing = yield* repo.findByEmail(parsed.email);
if (existing) {
return yield* new UserAlreadyExistsError({
email: parsed.email,
});
}
return yield* repo.create(parsed);
}),
updateUser: (id: string, input: unknown) =>
Effect.gen(function* () {
const parsed = yield* Schema.decodeUnknown(
UpdateUserSchema
)(input).pipe(
Effect.mapError(
(error) =>
new ValidationError({
field: "input",
message: `Invalid input: ${error.message}`,
})
)
);
yield* repo.findById(id);
return yield* repo.update(id, parsed);
}),
deleteUser: (id: string) => repo.delete(id),
listUsers: () => repo.list(),
};
})
);الخطوة 7: أنماط معالجة الأخطاء
توفر Effect عدة طرق قوية لمعالجة الأخطاء:
import { Effect, Match } from "effect";
import { UserService } from "./services/user-service.js";
// النمط 1: التقاط أخطاء محددة
const getUserSafe = (id: string) =>
Effect.gen(function* () {
const service = yield* UserService;
return yield* service.getUser(id).pipe(
Effect.catchTag("UserNotFoundError", () =>
Effect.succeed(null)
)
);
});
// النمط 2: مطابقة شاملة للأخطاء
const createUserWithHandling = (input: unknown) =>
Effect.gen(function* () {
const service = yield* UserService;
return yield* service.createUser(input).pipe(
Effect.catchAll((error) =>
Match.value(error).pipe(
Match.tag("ValidationError", (e) =>
Effect.succeed({ status: 400 as const, message: e.message })
),
Match.tag("UserAlreadyExistsError", (e) =>
Effect.succeed({ status: 409 as const, message: `${e.email} موجود` })
),
Match.tag("DatabaseError", (e) =>
Effect.succeed({ status: 500 as const, message: `خطأ قاعدة بيانات: ${e.operation}` })
),
Match.exhaustive
)
)
);
});
// النمط 3: إعادة المحاولة مع تراجع
const getUserWithRetry = (id: string) =>
Effect.gen(function* () {
const service = yield* UserService;
return yield* service.getUser(id).pipe(
Effect.retry({
times: 3,
schedule: "exponential",
while: (error) => error._tag === "DatabaseError",
})
);
});ملاحظة مهمة: على عكس try-catch، أخطاء Effect قابلة للتركيب. عندما تستدعي دالة يمكن أن تفشل بـ A | B، ثم تستدعي أخرى يمكن أن تفشل بـ C، يصبح نوع الخطأ الناتج تلقائياً A | B | C. لا تُبتلع أي أخطاء بصمت.
الخطوة 8: الأنابيب القابلة للتركيب
import { Effect, pipe, Array as Arr } from "effect";
import { UserService } from "./services/user-service.js";
const processUsers = Effect.gen(function* () {
const service = yield* UserService;
const users = yield* service.listUsers();
const processed = pipe(
users,
Arr.filter((user) => user.role === "admin"),
Arr.map((user) => ({
id: user.id,
displayName: user.name.toUpperCase(),
email: user.email,
})),
Arr.sort((a, b) => a.displayName.localeCompare(b.displayName))
);
return processed;
});
// تشغيل عدة effects بالتوازي
const fetchMultipleUsers = (ids: string[]) =>
Effect.gen(function* () {
const service = yield* UserService;
const users = yield* Effect.all(
ids.map((id) => service.getUser(id)),
{ concurrency: 5 }
);
return users;
});الخطوة 9: تجميع كل شيء معاً
أنشئ نقطة الدخول الرئيسية في src/main.ts:
import { Effect, Console, Layer } from "effect";
import { UserService, UserServiceLive } from "./services/user-service.js";
import { InMemoryUserRepository } from "./layers/in-memory-user-repository.js";
const program = Effect.gen(function* () {
const service = yield* UserService;
yield* Console.log("=== Effect-TS User Management ===\n");
const alice = yield* service.createUser({
name: "Alice Johnson",
email: "alice@example.com",
role: "admin",
});
yield* Console.log(`Created: ${alice.name} (${alice.id})`);
const bob = yield* service.createUser({
name: "Bob Smith",
email: "bob@example.com",
});
yield* Console.log(`Created: ${bob.name} (${bob.id})`);
// محاولة إنشاء مكرر
yield* service.createUser({
name: "Alice Clone",
email: "alice@example.com",
}).pipe(
Effect.catchTag("UserAlreadyExistsError", (e) =>
Console.log(`تم منع التكرار: ${e.email} موجود بالفعل`)
)
);
const allUsers = yield* service.listUsers();
yield* Console.log(`\nإجمالي المستخدمين: ${allUsers.length}`);
});
// ربط الطبقات — هنا يحدث حقن التبعيات
const MainLayer = UserServiceLive.pipe(
Layer.provide(InMemoryUserRepository)
);
const runnable = program.pipe(Effect.provide(MainLayer));
Effect.runPromise(runnable).then(() => {
console.log("\n✓ اكتمل البرنامج بنجاح");
});شغّله:
npm startالخطوة 10: الاختبار بتبديل الطبقات
أحد أكبر مزايا نمط الخدمات في Effect هو سهولة الاختبار. يمكنك تبديل أي طبقة في الاختبارات بدون مكتبات محاكاة.
import { Effect, Layer } from "effect";
import { UserService, UserServiceLive } from "./services/user-service.js";
import { UserRepository } from "./services/user-repository.js";
import { DatabaseError } from "./errors/user-errors.js";
// إنشاء مستودع اختبار يحاكي الفشل
const FailingRepository = Layer.succeed(UserRepository, {
findById: (id: string) =>
new DatabaseError({ operation: "findById", cause: "Connection refused" }),
findByEmail: () => Effect.succeed(null),
create: () =>
new DatabaseError({ operation: "create", cause: "Disk full" }),
update: () =>
new DatabaseError({ operation: "update", cause: "Timeout" }),
delete: () =>
new DatabaseError({ operation: "delete", cause: "Permission denied" }),
list: () => Effect.succeed([]),
});
const testDatabaseFailure = Effect.gen(function* () {
const service = yield* UserService;
const result = yield* service.getUser("user_1").pipe(
Effect.matchEffect({
onSuccess: () => Effect.succeed("FAIL: should have errored"),
onFailure: (error) => {
if (error._tag === "DatabaseError") {
return Effect.succeed(`PASS: Got DatabaseError for ${error.operation}`);
}
return Effect.succeed(`FAIL: Wrong error type: ${error._tag}`);
},
})
);
console.log(result);
});
const TestLayer = UserServiceLive.pipe(Layer.provide(FailingRepository));
Effect.runPromise(
testDatabaseFailure.pipe(Effect.provide(TestLayer))
);لا حاجة لمكتبة محاكاة. لأن التبعيات مُعرَّفة كواجهات ومُقدَّمة عبر الطبقات، يمكنك إنشاء أي تنفيذ اختبار — فاشل أو بطيء أو مُسجِّل أو مثالي — وتبديله. هذا حقن تبعيات حقيقي، وليس ترقيعاً.
الخطوة 11: أنماط متقدمة
المُهَل الزمنية والمقاطعة
import { Effect, Duration } from "effect";
const getUserWithTimeout = (id: string) =>
Effect.gen(function* () {
const service = yield* UserService;
return yield* service.getUser(id);
}).pipe(
Effect.timeout(Duration.seconds(5))
);إدارة الموارد
import { Effect } from "effect";
const withDatabaseConnection = Effect.acquireRelease(
Effect.sync(() => {
console.log("Opening database connection");
return { connectionId: Math.random().toString(36) };
}),
(connection) =>
Effect.sync(() => {
console.log(`Closing connection ${connection.connectionId}`);
})
);
const queryWithConnection = Effect.scoped(
Effect.gen(function* () {
const conn = yield* withDatabaseConnection;
console.log(`Using connection: ${conn.connectionId}`);
return "query result";
})
);استكشاف الأخطاء وإصلاحها
مشاكل شائعة
"Cannot find module 'effect'"
تأكد من تثبيت الحزمة: npm install effect.
"Type 'X' is not assignable to type 'Effect'"
على الأرجح نسيت yield* لـ effect داخل المولّد:
// خطأ
const user = service.getUser(id);
// صحيح
const user = yield* service.getUser(id);"Service not found: UserRepository"
نسيت تقديم الطبقة. تأكد من أن سلسلة Layer.provide() تتضمن جميع الخدمات المطلوبة.
الخطوات التالية
- @effect/platform — خادم HTTP ونظام الملفات وخدمات الطرفية مع Effect
- @effect/sql — استعلامات SQL مُنمَّطة باستخدام نمط خدمات Effect
- Effect Schema — الغوص العميق في تركيب المخططات والتحويلات
- Streams — معالجة البيانات اللانهائية مع نوع
Streamفي Effect
الخلاصة
تغيّر Effect-TS بشكل جذري طريقة كتابة TypeScript. بدلاً من الأمل في معالجة الأخطاء بشكل صحيح في وقت التشغيل، تحصل على ضمانات وقت التجميع بأن كل وضع فشل محسوب.
النقاط الرئيسية:
- الأخطاء المُنمَّطة تعني عدم التقاط
unknown— المترجم يتتبع كل فشل - الخدمات والطبقات تمنحك حقن تبعيات حقيقي بدون تكلفة وقت تشغيل
- المولّدات تجعل كود Effect يبدو ويعمل مثل TypeScript العادي
- الأنابيب القابلة للتركيب تستبدل سلاسل try-catch الهشة بتدفقات أنيقة
- الاختبار سهل — فقط بدّل الطبقات، بدون مكتبات محاكاة
ابدأ صغيراً: اختر خدمة واحدة في قاعدة الكود وحوّلها إلى Effect. بمجرد أن ترى أمان الأنواع في العمل، لن ترغب في العودة إلى try-catch.