Railway-Oriented Programming (ROP)
NOPE-PRO implements Railway-Oriented Programming, a functional programming pattern that makes error handling explicit and composable.
Imagine your code as a railway system:
1 Success Track 🟢
2 =================>
3 |
4 [Operation]
5 |
6 =================>
7 Failure Track 🔴
- Success Track: Happy path where everything works
- Failure Track: Error path when something goes wrong
- Operations: Transform or validate data, can switch tracks
Traditional vs Railway Approach
Traditional Approach - Hidden Complexity
1public Player LoadAndValidatePlayer(string playerId)
2{
3 try
4 {
5 // Multiple potential failure points, all hidden
6 var data = Database.Load(playerId); // Could throw
7 var player = JsonUtility.FromJson(data); // Could throw
8
9 if (player == null) // Null check
10 throw new Exception("Player is null");
11
12 if (player.Level < 0) // Validation
13 throw new Exception("Invalid level");
14
15 player.LastLogin = DateTime.Now; // Could throw
16 Database.Save(player); // Could throw
17
18 return player;
19 }
20 catch (DatabaseException dbEx)
21 {
22 // Handle database errors
23 Debug.LogError($"Database error: {dbEx}");
24 throw;
25 }
26 catch (JsonException jsonEx)
27 {
28 // Handle JSON errors
29 Debug.LogError($"JSON error: {jsonEx}");
30 throw;
31 }
32 catch (Exception ex)
33 {
34 // Handle everything else
35 Debug.LogError($"Unexpected error: {ex}");
36 throw;
37 }
38}
Railway Approach - Explicit Flow
1public Result<Player, string> LoadAndValidatePlayer(string playerId)
2{
3 return LoadFromDatabase(playerId) // Result<string, string>
4 .Bind(ParsePlayerJson) // Result<Player, string>
5 .Ensure(p => p.Level >= 0, "Invalid level") // Validation
6 .Map(p => { // Transform
7 p.LastLogin = DateTime.Now;
8 return p;
9 })
10 .Bind(SaveToDatabase); // Result<Player, string>
11}
Benefits of Railway Programming
- Explicit Error Handling: Errors are part of the type system
- Composability: Operations chain naturally
- No Hidden Exceptions: All failure modes are visible
- Testability: Each operation is isolated and testable
- Readability: The flow of data is clear and linear
The Result<T, E>
Type
Result<T, E>
is the core type that enables Railway Programming.
Anatomy of Result
1public readonly struct Result<T, E>
2{
3 public bool IsSuccess { get; }
4 public bool IsFailure { get; }
5 public T Value { get; } // Only valid when IsSuccess
6 public E Error { get; } // Only valid when IsFailure
7}
Creating Results
1// Direct creation
2var success = Result<int, string>.Success(42);
3var failure = Result<int, string>.Failure("Not found");
4
5// Implicit conversions
6Result<int, string> r1 = 42; // Success
7Result<int, string> r2 = "Error message"; // Failure
8
9// Conditional creation
10var r3 = Result.SuccessIf(age >= 18, age, "Too young");
11
12// From exception-throwing code
13var r4 = Result.Of(
14 () => int.Parse(userInput),
15 ex => $"Invalid number: {ex.Message}"
16);
Result Flow Operations
1Result<int, string> age = 25;
2
3Result<string, string> category = age.Map(a =>
4 a < 18 ? "Minor" :
5 a < 65 ? "Adult" :
6 "Senior"
7);
8// Success("Adult")
Bind - Chain Operations That Return Results
1Result<User, string> GetUser(int id) { ... }
2Result<Profile, string> GetProfile(User user) { ... }
3
4var profile = GetUser(123)
5 .Bind(user => GetProfile(user));
Ensure - Add Validation
1Result<int, string> score = 85;
2
3var validated = score
4 .Ensure(s => s >= 0, "Score cannot be negative")
5 .Ensure(s => s <= 100, "Score cannot exceed 100");
The Maybe<T>
Type
Maybe<T>
represents optional values without using null.
Why Maybe Instead of Null?
1// Problem with nulls
2string name = GetName(); // Could be null
3var length = name.Length; // NullReferenceException!
4
5// Solution with Maybe
6Maybe<string> name = GetName();
7Maybe<int> length = name.Map(n => n.Length); // Safe!
Creating Maybe Values
1// Direct creation
2Maybe<int> some = 42;
3Maybe<int> none = Maybe<int>.None;
4
5// From nullable
6int? nullable = GetNullableInt();
7Maybe<int> maybe = Maybe<int>.From(nullable);
8
9// Conditional
10Maybe<User> user = isLoggedIn ? currentUser : Maybe<User>.None;
Maybe Operations
1Maybe<string> name = "Alice";
2
3// Transform
4Maybe<int> length = name.Map(n => n.Length); // Maybe(5)
5
6// Filter
7Maybe<string> longName = name.Where(n => n.Length > 10); // None
8
9// Provide default
10Maybe<string> displayName = name.Or("Anonymous"); // If name is None, it will return "Anonymous"
11Debug.Log(displayName.Value); // Output: "Alice"
12
13// Pattern match
14string greeting = name.Match(
15 onValue: n => $"Hello, {n}!",
16 onNone: () => "Hello, stranger!"
17);
Combining Results and Maybe
NOPE-PRO allows seamless interaction between Result and Maybe:
1// Convert Maybe to Result - Manual conversion needed
2Maybe<User> maybeUser = GetCurrentUser();
3Result<User, string> userResult = maybeUser.HasValue
4 ? Result<User, string>.Success(maybeUser.Value)
5 : Result<User, string>.Failure("No user logged in");
6
7// Use validation in Result chains
8Result<string, string> LoadUserName(int id)
9{
10 return LoadUser(id)
11 .Ensure(user => !string.IsNullOrEmpty(user.Name), "User has no name")
12 .Map(user => user.Name.ToUpper());
13}
14
15// Alternative using Bind for more complex logic
16Result<string, string> LoadUserNameAlternative(int id)
17{
18 return LoadUser(id)
19 .Bind(user => string.IsNullOrEmpty(user.Name)
20 ? Result<string, string>.Failure("User has no name")
21 : Result<string, string>.Success(user.Name))
22 .Map(name => name.ToUpper());
23}
Error Types Best Practices
String Errors (Simple)
Good for prototyping and simple scenarios:
1Result<int, string> Divide(int a, int b)
2{
3 return b == 0 ? "Cannot divide by zero" : a / b;
4}
Enum Errors (Better)
More structured, good for known error cases:
1public enum MathError
2{
3 DivisionByZero,
4 Overflow,
5 InvalidInput
6}
7
8Result<int, MathError> Divide(int a, int b)
9{
10 return b == 0
11 ? Result<int, MathError>.Failure(MathError.DivisionByZero)
12 : Result<int, MathError>.Success(a / b);
13}
Custom Error Types (Best)
Most flexible, can carry additional context:
1public class ValidationError
2{
3 public string Field { get; }
4 public string Message { get; }
5 public object AttemptedValue { get; }
6
7 public ValidationError(string field, string message, object attemptedValue)
8 {
9 Field = field;
10 Message = message;
11 AttemptedValue = attemptedValue;
12 }
13}
14
15Result<User, ValidationError> ValidateAge(User user)
16{
17 return user.Age >= 0 && user.Age <= 150
18 ? Result<User, ValidationError>.Success(user)
19 : Result<User, ValidationError>.Failure(
20 new ValidationError("Age", "Age must be between 0 and 150", user.Age)
21 );
22}
The Unit Type
Unit
is used when you need a Result but don't care about the success value:
1// Usage
2Result.Of(() =>
3{
4 File.Delete("temp.txt");
5 return Unit.Value; // Success with no meaningful value
6}, exception => $"Failed to delete file: {exception.Message}") // If an exception occurs, return a failure Result with the error message
7.Tap(_ => Debug.Log("File deleted"))
8.Match(
9 onSuccess: _ => "Deleted successfully",
10 onFailure: error => $"Error: {error}"
11);
Visual Debugging Integration
The visual debugger tracks your Result flows:
1LoadPlayer(id)
2 .EnableDebug("PlayerLoad") // Start tracking
3 .Bind(ValidatePlayer) // Each step is recorded
4 .Map(ApplyBonus) // With timing and values
5 .Ensure(IsEligible, "Not eligible")
6 .Match<Unit>( // Final outcome tracked
7 onSuccess: player => { SavePlayer(player); return Unit.Value; },
8 onFailure: error => { LogError(error); return Unit.Value; }
9 );
Summary
NOPE-PRO's core concepts enable you to:
- Make errors explicit - No hidden exceptions
- Compose operations - Chain complex workflows
- Handle nulls safely - No null reference exceptions
- Debug visually - See your code flow
- Write cleaner code - Less nesting, more clarity