Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Result.value return type does not match the docs. #194

Open
Animii opened this issue Nov 18, 2024 · 6 comments
Open

Result.value return type does not match the docs. #194

Animii opened this issue Nov 18, 2024 · 6 comments
Labels
enhancement New feature or request

Comments

@Animii
Copy link

Animii commented Nov 18, 2024

Is your feature request related to a problem? Please describe.
The docs in the Result class say, that the value method returns null if result is a failure.
But the return type is T and not T | null.
This could lead to bugs where the null case it not handled.

Describe the solution you'd like
I would like the return type to be T | null.

Describe alternatives you've considered
Since this would break a lot of code bases, we could at a valueOrNull function.

Additional context
I don't know if this is intentional, but it took me by surprise.

Awesome project ❤️

@4lessandrodev
Copy link
Owner

Thank You

Hi, Animii!

I want to thank you for your feedback regarding the value() method typing in the Result class. Your observation about the inconsistency between the documentation and the actual behavior was good and valid.

I’ve adjusted the typing to explicitly reflect that the value will be null in failure cases and introduced the isNull() method to simplify these checks.


Example with the Resolution

With the changes, here’s an example of how to use the updated implementation:

const result = Result.fail("An error occurred");

// Checking if the value is `null` or if the state is a failure
if (result.isNull()) console.log("The result is null or in a failure state:", result.error());


// Using it in a factory method
class UserEntity {
    private constructor(public readonly name: string) {}

                                             // Ensure provide null possibly option type
    public static create(name: string): Result<UserEntity | null> {
        if (!name) return Result.fail("Name is required");
        return Result.Ok(new UserEntity(name));
    }
}

const userResult = UserEntity.create("");


// now value is possibly null
console.log("User created:", userResult.value()?.name);

Beta Version for Testing

This resolution will be published in 1.23.5.beta-0 for testing. Any feedback you provide will be greatly appreciated as we continue to enhance the library. Once again, thank you so much for your contribution! 🚀

@GaetanCottrez
Copy link
Contributor

GaetanCottrez commented Dec 20, 2024

🚨🚨🚨

I understand the adjustments made to the typing @4lessandrodev , but ultimately, it's not great and I think it’s a major regression because it makes things more complex and we end up having to handle more cases.

This change introduces a huge breaking change in the existing code using your type-ddd library. When we used Result.fail(), we now have to handle a null value systematically, which in most cases (if not all cases) we don't need to manage.

If I take your example, before we simply had to do this:

// Using it in a factory method
class UserEntity {
    private constructor(public readonly name: string) {}

                                             // Ensure provide null possibly option type
    public static create(name: string): Result<UserEntity | null> {
        if (!name) return Result.fail("Name is required");
        return Result.Ok(new UserEntity(name));
    }
}

const userResult = UserEntity.create("");

if(userResult.isFail()) return Result.fail("An error occurred in creating user ");

console.log("User created:", userResult.value().name); // userResult is typing UserEntity

With your modification, userResult is now UserEntity | null.

Any access to a property of the entity or its use in a method (like saving it in a repository) now has to be typed as UserEntity | null, which makes the usage much harder to manage.

Another example from my context after integrating your change:

Capture d’écran 2024-12-20 à 21 28 19

In the screenshot, you can see the issue.

emailValueObject.value() is set to null even though I am checking it with if (emailValueObject.isFail()) return Result.fail(emailValueObject.error());

And my repository doesn't account for null, and with your change, I’m forced to handle it.

We lose a lot of coherence and intuitiveness with this change.

I think this needs to be reviewed urgently, and I suggest that, in the meantime, we revert this modification.

@4lessandrodev
Copy link
Owner

Hi @GaetanCottrez,

Thank you for your feedback and for highlighting the challenges caused by the recent changes. To address your concerns, I’ve updated the approach to make the dynamic typing in Result optional. This solution caters to both scenarios:

  1. Explicit Handling of null: For cases where developers want Result<T | null> and need to handle null explicitly.
  2. Non-Nullable Values: For cases where null handling is unnecessary, allowing Result<T> to be inferred directly.

This provides flexibility without imposing strict changes that disrupt existing integrations.


How It Works

Developers can now choose whether to include null in the type inference of Result based on their specific use case. Below are examples for both scenarios:

Scenario 1: Handling null Explicitly

This is useful when you want to represent potential failure states directly in the Result type.

type Props = { name: string };

class SampleNullish extends Entity<Props> {
    private constructor(props: Props) {
        super(props);
    }

    public static create(props: Props): Result<SampleNullish | null> {
        // Explicitly typed to allow null
        if (!props.name || props.name.trim() === '') {
            return Fail('name is required');
        }
        return Ok(new SampleNullish(props));
    }
}

// Usage
const validProps = { name: 'Valid Name' };
const result = SampleNullish.create(validProps);

// Handle null explicitly
const value = result.value();
expect(value?.get('name')).toBe('Valid Name'); // Safe access with optional chaining

In this example, the Result is explicitly typed as Result<SampleNullish | null>, ensuring that the developer treats the result as nullable and uses optional chaining or explicit checks.


Scenario 2: Non-Nullable Values

When null is not a valid state, you can omit it from the type, simplifying the usage:

type Props = { name: string };

class Sample extends Entity<Props> {
    private constructor(props: Props) {
        super(props);
    }

    public static create(props: Props): Result<Sample> {
        // No null possible, ensuring non-null values
        if (!props.name || props.name.trim() === '') {
            return Fail('name is required');
        }
        return Ok(new Sample(props));
    }
}

// Usage
const validProps = { name: 'Valid Name' };
const result = Sample.create(validProps);

expect(result.isOk()).toBe(true);

// Confident non-null access
const value = result.value();
expect(value.get('name')).toBe('Valid Name');

In this scenario, the Result is simply Result<Sample>, removing the need for null checks and simplifying the code.


Benefits of the Updated Approach

  • Flexibility: Developers can opt for Result<T | null> or Result<T> based on their use case.
  • Backward Compatibility: Existing integrations can continue using Result<T> without disruption.
  • Clarity: Encourages better type safety and responsibility for handling potential null values.
  • Ease of Use: Simplifies common cases where null checks are unnecessary while maintaining the option for explicit handling.

Closing Thoughts

This update aims to balance type safety and usability by making dynamic typing optional. Let me know if this approach resolves your concerns or if further refinements are needed!

@4lessandrodev
Copy link
Owner

New Contract Added: init Method in Domain

I’ve recently introduced a new method, init, to the Domain value object and also available in Entity and Aggregate. This method offers a simpler and more streamlined approach to instance creation by removing the client’s responsibility of validating whether the instantiation was successful.

What is init?

The init method is designed to always return a valid instance of Domain. If the provided value is invalid, it throws an exception, ensuring that the calling code does not need to handle the result validation directly.

How Does This Help?

  1. Simplified Usage:
    Developers no longer need to handle Result objects or check if the creation succeeded. The method guarantees a valid instance or throws an error for invalid input.

  2. Encouraged Error Handling:
    By combining this method with try/catch in lower layers (e.g., infrastructure), you can centralize error handling and reduce repetitive validation logic in upper layers like application services, use case or domain service.


Recommended Usage

Example using phone number available on @type-ddd/phone

In the Domain Layer

Use the init method to ensure a valid MobilePhone instance during domain-level operations:

const mobilePhone = MobilePhone.init("(11) 91234-5678");
console.log(mobilePhone.toCall()); // "011912345678"

In the Infrastructure Layer

Catch any errors resulting from invalid input in lower layers, allowing domain logic to remain clean and focused:

try {
    const mobilePhone = MobilePhone.init("(11) 91234-5678");
    repository.save(mobilePhone);
} catch (error) {
    console.error("Failed to initialize MobilePhone:", error.message);
    // Handle or rethrow error as needed
}

Why Use init Over create?

  • init:

    • Guarantees a valid instance or throws an error.
    • Reduces the burden on the calling code to handle validation.
    • Suitable for scenarios where exceptions align with your error-handling strategy.
  • create:

    • Returns a Result<MobilePhone | null>, requiring validation checks by the caller.
    • Best used in cases where explicit result handling is preferred or necessary.

Conclusion

The init method is an excellent addition for developers looking to simplify domain layer operations and enforce clear error propagation. While create remains useful in scenarios requiring explicit validation, I encourage you to adopt init alongside try/catch in infrastructure layers for a more intuitive and maintainable design.

This approach ensures that errors are handled where they are most relevant, leaving upper layers free to focus on business logic.

@GaetanCottrez
Copy link
Contributor

GaetanCottrez commented Dec 21, 2024

Hi @GaetanCottrez,

Thank you for your feedback and for highlighting the challenges caused by the recent changes. To address your concerns, I’ve updated the approach to make the dynamic typing in Result optional. This solution caters to both scenarios:

  1. Explicit Handling of null: For cases where developers want Result<T | null> and need to handle null explicitly.
  2. Non-Nullable Values: For cases where null handling is unnecessary, allowing Result<T> to be inferred directly.

This provides flexibility without imposing strict changes that disrupt existing integrations.

How It Works

Developers can now choose whether to include null in the type inference of Result based on their specific use case. Below are examples for both scenarios:

Scenario 1: Handling null Explicitly

This is useful when you want to represent potential failure states directly in the Result type.

type Props = { name: string };

class SampleNullish extends Entity<Props> {
    private constructor(props: Props) {
        super(props);
    }

    public static create(props: Props): Result<SampleNullish | null> {
        // Explicitly typed to allow null
        if (!props.name || props.name.trim() === '') {
            return Fail('name is required');
        }
        return Ok(new SampleNullish(props));
    }
}

// Usage
const validProps = { name: 'Valid Name' };
const result = SampleNullish.create(validProps);

// Handle null explicitly
const value = result.value();
expect(value?.get('name')).toBe('Valid Name'); // Safe access with optional chaining

In this example, the Result is explicitly typed as Result<SampleNullish | null>, ensuring that the developer treats the result as nullable and uses optional chaining or explicit checks.

Scenario 2: Non-Nullable Values

When null is not a valid state, you can omit it from the type, simplifying the usage:

type Props = { name: string };

class Sample extends Entity<Props> {
    private constructor(props: Props) {
        super(props);
    }

    public static create(props: Props): Result<Sample> {
        // No null possible, ensuring non-null values
        if (!props.name || props.name.trim() === '') {
            return Fail('name is required');
        }
        return Ok(new Sample(props));
    }
}

// Usage
const validProps = { name: 'Valid Name' };
const result = Sample.create(validProps);

expect(result.isOk()).toBe(true);

// Confident non-null access
const value = result.value();
expect(value.get('name')).toBe('Valid Name');

In this scenario, the Result is simply Result<Sample>, removing the need for null checks and simplifying the code.

Benefits of the Updated Approach

  • Flexibility: Developers can opt for Result<T | null> or Result<T> based on their use case.
  • Backward Compatibility: Existing integrations can continue using Result<T> without disruption.
  • Clarity: Encourages better type safety and responsibility for handling potential null values.
  • Ease of Use: Simplifies common cases where null checks are unnecessary while maintaining the option for explicit handling.

Closing Thoughts

This update aims to balance type safety and usability by making dynamic typing optional. Let me know if this approach resolves your concerns or if further refinements are needed!

@4lessandrodev Dude! You're very fast and very efficient! A huge thank you once again for your work and your library.

When you have time, could you upgrade the version of rich-domain in types-ddd and publish a new version? Also, remember to add the examples described in this issue to the documentation.

@GaetanCottrez
Copy link
Contributor

GaetanCottrez commented Dec 21, 2024

New Contract Added: init Method in Domain

I’ve recently introduced a new method, init, to the Domain value object and also available in Entity and Aggregate. This method offers a simpler and more streamlined approach to instance creation by removing the client’s responsibility of validating whether the instantiation was successful.

What is init?

The init method is designed to always return a valid instance of Domain. If the provided value is invalid, it throws an exception, ensuring that the calling code does not need to handle the result validation directly.

How Does This Help?

  1. Simplified Usage:
    Developers no longer need to handle Result objects or check if the creation succeeded. The method guarantees a valid instance or throws an error for invalid input.
  2. Encouraged Error Handling:
    By combining this method with try/catch in lower layers (e.g., infrastructure), you can centralize error handling and reduce repetitive validation logic in upper layers like application services, use case or domain service.

Recommended Usage

Example using phone number available on @type-ddd/phone

In the Domain Layer

Use the init method to ensure a valid MobilePhone instance during domain-level operations:

const mobilePhone = MobilePhone.init("(11) 91234-5678");
console.log(mobilePhone.toCall()); // "011912345678"

In the Infrastructure Layer

Catch any errors resulting from invalid input in lower layers, allowing domain logic to remain clean and focused:

try {
    const mobilePhone = MobilePhone.init("(11) 91234-5678");
    repository.save(mobilePhone);
} catch (error) {
    console.error("Failed to initialize MobilePhone:", error.message);
    // Handle or rethrow error as needed
}

Why Use init Over create?

  • init:

    • Guarantees a valid instance or throws an error.
    • Reduces the burden on the calling code to handle validation.
    • Suitable for scenarios where exceptions align with your error-handling strategy.
  • create:

    • Returns a Result<MobilePhone | null>, requiring validation checks by the caller.
    • Best used in cases where explicit result handling is preferred or necessary.

Conclusion

The init method is an excellent addition for developers looking to simplify domain layer operations and enforce clear error propagation. While create remains useful in scenarios requiring explicit validation, I encourage you to adopt init alongside try/catch in infrastructure layers for a more intuitive and maintainable design.

This approach ensures that errors are handled where they are most relevant, leaving upper layers free to focus on business logic.

I’ll try to use this new approach when I get the chance @4lessandrodev.

However, the try/catch you mention for the infrastructure, isn’t that more for the application layer?

Because normally (and in your example applications in other repositories), the domain is implemented by the application, which in turn is implemented by the infrastructure.

So the infrastructure doesn't have knowledge of the domain, only the application, and therefore, it should be the one using init() (I’m using type-ddd in a hexagonal architecture).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

3 participants