Rotomeca.Rop
Gestion d'erreurs typée à la Railway Oriented Programming pour C#. Inspiré du pattern Result<T, E> de Rust et des bonnes pratiques fonctionnelles, Rotomeca.Rop fournit une API cohérente, sûre et composable pour gérer les opérations pouvant échouer — sans exceptions intempestives, sans null silencieux.
Pensé pour s'aligner avec ses équivalents @rotomeca/rop (TypeScript) et rotomeca/rop (PHP), afin d'offrir des signatures et comportements quasi-identiques entre les trois langages.
Installation
dotnet add package Rotomeca.Rop
Compatibilité
| Environnement | Support |
|---|---|
| .NET Standard | 2.1 |
| .NET | 8.0, 9.0 |
| Source Link | ✅ (navigation vers le code depuis le débogueur) |
| Symbols | ✅ (.snupkg) |
| Nullable | ✅ (activé) |
| Générateur | ✅ ([Risky] / [Risky(Async = true)] + Roslyn) |
Concepts clés
La librairie s'articule autour de quatre piliers :
| Élément | Rôle |
|---|---|
IResult<TSuccess, TError> |
Résultat synchrone d'une opération — succès ou erreur, jamais les deux |
IResultAsync<TSuccess, TError> |
Pendant asynchrone — pipeline sans await intermédiaire, directement awaitable |
IError |
Erreur structurée : message, type sémantique, code applicatif, exception d'origine |
[Risky] / [Risky(Async = true)] |
Génère automatiquement un wrapper Result.Try ou ResultAsync.TryAsync |
Démarrage rapide
Créer un résultat synchrone
using Rotomeca.Rop;
// Succès
IResult<User> result = Result.Ok(new User(1, "Alice"));
// Erreur structurée
IResult<User> result = Result.Fail<User>(
new Error("Utilisateur introuvable", ErrorType.NotFound, code: "USR_404"));
// Capture d'exception
IResult<int, IError> result = Result.Try(() => int.Parse(input));
Créer un résultat asynchrone
// Depuis une Task<T>
IResultAsync<User> result = ResultAsync.Ok(FetchUserFromDbAsync(id));
// Capture d'exception synchrone (task froide)
IResultAsync<User, IError> result = ResultAsync.Try(() => new Task<User>(...));
// Capture d'exception async complète (méthodes async)
Task<IResultAsync<User, IError>> result = ResultAsync.TryAsync(() => FetchUserAsync(id));
Composer un pipeline synchrone
var message = FindUser(userId)
.AndThen(user => PlaceOrder(user, amount))
.Map(order => order.Amount)
.Match(
Ok: amount => $"Commande confirmée : {amount:C}",
Err: error => $"Échec ({error.Code}) : {error.Message}"
);
Composer un pipeline asynchrone
// Chaînage sans await intermédiaire — un seul await à la fin
IResult<decimal> result = await FindUserAsync(userId)
.AndThen(user => PlaceOrderAsync(user, amount))
.MapAsync(order => EnrichOrderAsync(order))
.Tap(order => logger.Info("Commande {id}", order.Id));
string message = result.Match(
Ok: amount => $"Confirmée : {amount:C}",
Err: error => $"Échec : {error.Message}"
);
Générer un wrapper [Risky]
public partial class UserService
{
// Génère : public IResult<User, IError> Fetch(int id)
[Risky]
private User _Fetch(int id)
=> _repository.GetById(id) ?? throw new KeyNotFoundException();
// Génère : public Task<IResultAsync<User, IError>> FetchAsync(int id)
[Risky(Async = true)]
private async Task<User> _FetchAsync(int id)
{
var user = await _repository.GetByIdAsync(id);
return user ?? throw new KeyNotFoundException();
}
}
Structure du dépôt
Rotomeca.Rop/
├── src/
│ ├── Rotomeca.Rop.Core/ # Librairie principale (IResult, IResultAsync, Error…)
│ └── Rotomeca.Rop.Generators/ # Générateur Roslyn ([Risky] sync et async)
└── tests/
└── Rotomeca.Rop.Core.Tests/ # Suite de tests xUnit (sync + async)
ErrorType — catégories sémantiques
ErrorType est un record struct extensible. Les catégories prédéfinies couvrent les cas courants :
| Valeur | Usage |
|---|---|
Unexpected |
Erreur inattendue ou non catégorisée (défaut) |
Validation |
Données d'entrée invalides |
NotFound |
Ressource introuvable |
Unauthorized |
Accès non autorisé ou permissions manquantes |
Conflict |
Conflit d'état sur la ressource |
public static class AppErrors
{
public static readonly ErrorType PaymentRequired = new("PaymentRequired");
public static readonly ErrorType RateLimit = new("RateLimit");
}
Référence des exports publics
// Factory synchrone
using Rotomeca.Rop;
Result.Ok<T>(value) Result.Ok<T, E>(value)
Result.Fail<T>(error) Result.Fail<T, E>(error)
Result.Fail(error) Result.Try(fn)
Result.Throw(...)
// Factory asynchrone
ResultAsync.Ok<T>(task) ResultAsync.Ok<T, E>(task)
ResultAsync.Fail<T>(task) ResultAsync.Fail<T, E>(task)
ResultAsync.Try(fn) ResultAsync.TryAsync(fn)
ResultAsync.Throw(...)
// Interfaces synchrones
using Rotomeca.Rop.Interfaces;
IResult<TSuccess, TError> IResult<TSuccess> IEmptyResult
// Interfaces asynchrones
IResultAsync<TSuccess, TError> IResultAsync<TSuccess> IEmptyResultAsync
// Modèle d'erreur
IError Error Error<TData> ErrorType
// Extensions
using Rotomeca.Rop.Extensions;
.AsResult() .WithError(mapper) .As(value) .As(factory)
// Générateur
[Risky] // wrapper Result.Try (sync)
[Risky(Async = true)] // wrapper ResultAsync.TryAsync (async)
Ecosystème Rotomeca
| Package | Langage | Description |
|---|---|---|
Rotomeca.Rop |
C# | Ce package |
@rotomeca/rop |
TypeScript | Gestion d'erreurs typée (Result<T, E>) |
@rotomeca/event |
TypeScript | Système d'événements typés à la C# |
@rotomeca/utils |
TypeScript | Fonctions pures, types brandés et helpers |
@rotomeca/jsenumerable |
TypeScript | LINQ lazy en TypeScript |
Contribuer
git clone https://github.com/Rotomeca/rop.git
cd rop
dotnet restore
dotnet test
Les contributions sont les bienvenues via Pull Request sur la branche dev.
Note sur l'utilisation de l'IA
L'intégralité du code de ce projet a d'abord été écrite à la main en essayant d'avoir le C# le plus propre possible. L'IA a ensuite été utilisée pour :
- Proposer des axes d'amélioration et de refactorisation si besoin, après relecture de ses modifications par mes soins
- La documentation et les README — j'ai toujours été une bille en documentation, je trouve celle de l'IA lisible et explicite ; elle a toujours été relue et validée par mes soins
- Les tests unitaires — tester, c'est facile, mais présenter des tests unitaires, c'est complexe (de mon point de vue) ; l'IA a dans un premier temps généré les tests, je les ai parcourus pour les comprendre et les corriger au besoin
- La CI/CD — vu que ce n'est pas mon domaine, mais ça permet d'apprendre beaucoup 👍
Sa principale contribution a donc été de m'accompagner sur les points qui me sont lacunaires.
Licence
ISC © Rotomeca