Effect-TS : Gestion des erreurs typée, services et pipelines pour TypeScript en production

Arrêtez de perdre vos erreurs dans les trous noirs du try-catch. Effect-TS vous offre des erreurs typées, des pipelines composables et une injection de dépendances intégrée — tout en restant 100% TypeScript. Dans ce tutoriel, vous construirez un service de gestion des utilisateurs prêt pour la production avec des erreurs tracées, des tentatives automatiques et des dépendances testables.
Ce que vous apprendrez
À la fin de ce tutoriel, vous saurez :
- Comprendre ce que fait Effect-TS et pourquoi son adoption explose en 2026
- Modéliser des erreurs typées que le compilateur suit à travers toute votre application
- Construire des pipelines composables avec
Effect.pipeet les générateurs - Implémenter l'injection de dépendances avec le pattern Service/Layer
- Gérer la concurrence avec des primitives de concurrence structurée
- Ajouter des tentatives, timeouts et planifications sans code supplémentaire
- Écrire des services testables en échangeant les layers dans les tests
- Construire une API de gestion des utilisateurs complète de zéro
Prérequis
Avant de commencer, assurez-vous de disposer de :
- Node.js 20+ installé (
node --version) - Expérience en TypeScript 5.4+ (génériques, inférence de types, unions discriminées)
- Familiarité avec les patterns async/await
- Compréhension de base des concepts d'injection de dépendances
- Un éditeur de code avec support TypeScript (VS Code recommandé)
Pourquoi Effect-TS ?
La gestion des erreurs traditionnelle en TypeScript a un problème fondamental : try-catch efface les informations de type. Quand vous attrapez une erreur, TypeScript la type comme unknown — vous ne savez pas ce qui a échoué au moment de la compilation.
// Approche traditionnelle — les erreurs sont invisibles pour le système de types
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();
}
// Quelles erreurs cette fonction peut-elle lever ?
// La signature ne le dit pas.
// Peut-être : NetworkError, NotFoundError, ParseError, TimeoutError...Effect-TS résout ce problème en encodant les erreurs dans la signature du type :
import { Effect } from "effect";
// Effect<User, NetworkError | NotFoundError, UserService>
// Type succès ↑ Types erreur ↑ Dépendances ↑Chaque fonction déclare exactement ce qui peut mal tourner et de quelles dépendances elle a besoin. Le compilateur impose cela à chaque point d'appel.
Étape 1 : Configuration du projet
Créez un nouveau projet et installez 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/nodeInitialisez TypeScript avec des paramètres stricts :
npx tsc --initMettez à jour votre 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/**/*"]
}Créez la structure du projet :
mkdir -p src/{errors,services,layers,models}Ajoutez un script de lancement dans package.json :
{
"scripts": {
"dev": "tsx watch src/main.ts",
"start": "tsx src/main.ts"
}
}Étape 2 : Comprendre le type Effect
Le cœur d'Effect-TS est le type Effect. Pensez-y comme un Promise suralimenté qui suit trois choses :
// Effect<Success, Error, Requirements>
//
// Success — la valeur produite en cas de succès
// Error — les types d'erreurs possibles (union)
// Requirements — les services/dépendances nécessairesCréez src/basics.ts pour explorer les fondamentaux :
import { Effect, Console } from "effect";
// Un Effect simple qui réussit avec une chaîne
const greeting = Effect.succeed("Hello, Effect!");
// Un Effect simple qui échoue avec une erreur
const failure = Effect.fail("Something went wrong");
// Les Effects sont paresseux — rien ne s'exécute tant que vous ne les lancez pas
// C'est juste une description d'un calcul
// Transformer les valeurs avec map
const loudGreeting = greeting.pipe(
Effect.map((msg) => msg.toUpperCase())
);
// Utiliser les générateurs pour un style impératif (recommandé)
const generatorProgram = Effect.gen(function* () {
const msg = yield* greeting;
yield* Console.log(msg);
yield* Console.log("Effect-TS is awesome!");
});
// Exécuter le programme
Effect.runPromise(generatorProgram).then(() => {
console.log("Done!");
});Exécutez-le :
npx tsx src/basics.tsVous devriez voir :
Hello, Effect!
Effect-TS is awesome!
Done!
Générateurs vs pipe : Les deux styles sont entièrement équivalents. Les générateurs (Effect.gen) vous donnent un code impératif avec yield*, tandis que pipe offre un style de composition fonctionnel. La plupart des équipes préfèrent les générateurs pour la lisibilité. Utilisez celui qui convient le mieux à votre équipe.
Étape 3 : Définir des erreurs typées
La vraie puissance d'Effect commence avec les erreurs typées. Au lieu de lancer des objets Error génériques, vous définissez des classes d'erreurs spécifiques que le système de types suit.
Créez src/errors/user-errors.ts :
import { Data } from "effect";
// Data.TaggedError crée un membre d'union taguée avec un champ _tag
// Cela permet le pattern matching exhaustif
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;
}> {}Maintenant utilisez-les dans une fonction :
import { Effect } from "effect";
import { UserNotFoundError, ValidationError } from "./errors/user-errors.js";
// Le type de retour déclare explicitement : peut échouer avec
// UserNotFoundError OU 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;
});
}Le compilateur sait maintenant exactement quelles erreurs getUser peut produire. Si vous appelez getUser et oubliez de gérer UserNotFoundError, le système de types vous le signalera.
Étape 4 : Construire le modèle utilisateur
Créez src/models/user.ts en utilisant @effect/schema pour la validation à l'exécution :
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,
});
export type User = typeof UserSchema.Type;
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;Étape 5 : Créer des services avec injection de dépendances
Effect-TS possède un système d'injection de dépendances intégré utilisant les Services et les Layers. Les Services définissent les capacités nécessaires ; les Layers fournissent l'implémentation.
Créez 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";
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>;
}
>() {}Puis créez une implémentation en mémoire dans 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,
} 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());
}),
};
})
);Étape 6 : Construire le service utilisateur
Créez maintenant un service de niveau supérieur qui ajoute la logique métier au-dessus du repository. Créez 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: `Entrée invalide : ${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: `Entrée invalide : ${error.message}`,
})
)
);
yield* repo.findById(id);
return yield* repo.update(id, parsed);
}),
deleteUser: (id: string) => repo.delete(id),
listUsers: () => repo.list(),
};
})
);Étape 7 : Patterns de gestion des erreurs
Effect vous offre plusieurs façons puissantes de gérer les erreurs :
import { Effect, Match } from "effect";
import { UserService } from "./services/user-service.js";
// Pattern 1 : Attraper des erreurs spécifiques
const getUserSafe = (id: string) =>
Effect.gen(function* () {
const service = yield* UserService;
return yield* service.getUser(id).pipe(
Effect.catchTag("UserNotFoundError", () =>
Effect.succeed(null)
)
);
});
// Pattern 2 : Matching exhaustif des erreurs
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} existe déjà` })
),
Match.tag("DatabaseError", (e) =>
Effect.succeed({ status: 500 as const, message: `Erreur base de données : ${e.operation}` })
),
Match.exhaustive
)
)
);
});
// Pattern 3 : Retry avec backoff exponentiel
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",
})
);
});Point clé : Contrairement à try-catch, les erreurs Effect se composent. Quand vous appelez une fonction qui peut échouer avec A | B, puis une autre qui peut échouer avec C, le type d'erreur résultant est automatiquement A | B | C. Aucune erreur n'est silencieusement avalée.
Étape 8 : Pipelines composables
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;
});
// Exécuter plusieurs effects en parallèle
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;
});Étape 9 : Tout assembler
Créez le point d'entrée principal dans 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(`Créé : ${alice.name} (${alice.id})`);
const bob = yield* service.createUser({
name: "Bob Smith",
email: "bob@example.com",
});
yield* Console.log(`Créé : ${bob.name} (${bob.id})`);
// Tentative de création de doublon
yield* service.createUser({
name: "Alice Clone",
email: "alice@example.com",
}).pipe(
Effect.catchTag("UserAlreadyExistsError", (e) =>
Console.log(`Doublon empêché : ${e.email} existe déjà`)
)
);
const allUsers = yield* service.listUsers();
yield* Console.log(`\nTotal utilisateurs : ${allUsers.length}`);
});
// Câbler les layers — c'est ici que l'injection de dépendances se produit
const MainLayer = UserServiceLive.pipe(
Layer.provide(InMemoryUserRepository)
);
const runnable = program.pipe(Effect.provide(MainLayer));
Effect.runPromise(runnable).then(() => {
console.log("\n✓ Programme terminé avec succès");
});Exécutez-le :
npm startÉtape 10 : Tester avec des swaps de layers
L'un des plus grands avantages du pattern de services d'Effect est la testabilité. Vous pouvez échanger n'importe quel layer dans les tests sans bibliothèque de mocking.
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";
// Créer un repository de test qui simule les erreurs
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))
);Pas besoin de bibliothèque de mocking. Parce que les dépendances sont définies comme des interfaces et fournies via des layers, vous pouvez créer n'importe quelle implémentation de test — défaillante, lente, enregistreuse ou parfaite — et l'échanger. C'est une vraie injection de dépendances, pas du monkey-patching.
Étape 11 : Patterns avancés
Timeouts et interruption
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))
);Gestion des ressources
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";
})
);Dépannage
Problèmes courants
"Cannot find module 'effect'"
Assurez-vous d'avoir installé le package : npm install effect.
"Type 'X' is not assignable to type 'Effect'"
Vous avez probablement oublié le yield* d'un effect dans un générateur :
// Faux
const user = service.getUser(id);
// Correct
const user = yield* service.getUser(id);"Service not found: UserRepository"
Vous avez oublié de fournir le layer. Vérifiez que votre chaîne Layer.provide() inclut tous les services requis.
Prochaines étapes
- @effect/platform — Serveur HTTP, système de fichiers et services terminal avec Effect
- @effect/sql — Requêtes SQL typées utilisant le pattern de services d'Effect
- Effect Schema — Plongée approfondie dans la composition de schémas et les transformations
- Streams — Traiter des données infinies avec le type
Streamd'Effect
Conclusion
Effect-TS change fondamentalement la façon dont vous écrivez du TypeScript. Au lieu d'espérer que les erreurs soient correctement gérées à l'exécution, vous obtenez des garanties au moment de la compilation que chaque mode de défaillance est pris en compte.
Les points clés à retenir :
- Les erreurs typées signifient plus de captures
unknown— le compilateur suit chaque défaillance - Les Services et Layers vous donnent une vraie injection de dépendances sans surcharge à l'exécution
- Les générateurs font que le code Effect ressemble et fonctionne comme du TypeScript normal
- Les pipelines composables remplacent les chaînes try-catch fragiles par des flux élégants
- Les tests sont triviaux — échangez simplement les layers, sans bibliothèque de mocking
Commencez petit : choisissez un service dans votre codebase et convertissez-le en Effect. Une fois que vous aurez vu la sécurité des types en action, vous ne voudrez plus revenir au try-catch.
Discutez de votre projet avec nous
Nous sommes ici pour vous aider avec vos besoins en développement Web. Planifiez un appel pour discuter de votre projet et comment nous pouvons vous aider.
Trouvons les meilleures solutions pour vos besoins.
Articles connexes

Construire un Agent IA Autonome avec Agentic RAG et Next.js
Apprenez a construire un agent IA qui decide de maniere autonome quand et comment recuperer des informations depuis des bases de donnees vectorielles. Un guide pratique complet avec Vercel AI SDK et Next.js, accompagne d'exemples executables.

Construire des agents IA from scratch avec TypeScript : maîtriser le pattern ReAct avec le Vercel AI SDK
Apprenez à construire des agents IA depuis zéro avec TypeScript. Ce tutoriel couvre le pattern ReAct, l'appel d'outils, le raisonnement multi-étapes et les boucles d'agents prêtes pour la production avec le Vercel AI SDK.

Better Auth avec Next.js 15 : Le Guide Complet d'Authentification pour 2026
Apprenez à implémenter une authentification complète dans Next.js 15 avec Better Auth. Ce tutoriel couvre email/mot de passe, OAuth, sessions, protection des routes et contrôle d'accès basé sur les rôles.