Core Concepts

Understanding the philosophy behind NOPE-PRO will help you write better, more maintainable code.

Railway-Oriented Programming (ROP)

NOPE-PRO implements Railway-Oriented Programming, a functional programming pattern that makes error handling explicit and composable.

The Railway Metaphor

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

  1. Explicit Error Handling: Errors are part of the type system
  2. Composability: Operations chain naturally
  3. No Hidden Exceptions: All failure modes are visible
  4. Testability: Each operation is isolated and testable
  5. 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

Map - Transform Success Values

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:

  1. Make errors explicit - No hidden exceptions
  2. Compose operations - Chain complex workflows
  3. Handle nulls safely - No null reference exceptions
  4. Debug visually - See your code flow
  5. Write cleaner code - Less nesting, more clarity