laravel-data icon indicating copy to clipboard operation
laravel-data copied to clipboard

feat: support optional properties

Open innocenzi opened this issue 2 years ago • 9 comments

Original PR: https://github.com/spatie/laravel-data/pull/127 (was closed due to the deletion of the v2 branch)


This PR is a second take at https://github.com/spatie/laravel-data/pull/119, that does not replace the default behavior but supports the newly configurable one.

It depends on https://github.com/spatie/typescript-transformer/pull/30 and https://github.com/spatie/laravel-typescript-transformer/pull/15. Both of these should be merged before this PR (tests will not pass anyway).

What's important:

  • The #[Optional] attribute from typescript-transformer is taken into account
  • The new transform_null_to_optional option from laravel-typescript-transformer is taken into account
  • The current behavior does not change unless one of the above is true

Example:

use Spatie\TypeScriptTransformer\Attributes\Optional;

class UserData
{
    public function __construct(
        #[Optional]
        public int $id,
        public string $first_name,
    ) {
    }
}
interface UserData {
  id?: number
  first_name: string
}

The Optional attribute above would not be necessary if config/typescript-transformer.php's transform_null_to_optional is set to true (which is false by default to preserve current behavior).

innocenzi avatar Jul 08 '22 09:07 innocenzi

@innocenzi - Why would this not be based on whether the property is nullable?

Example:

class UserData
{
    public function __construct(
        public ?int $id,
        public string $first_name,
    ) {
    }
}

aidan-casey avatar Jul 27 '22 17:07 aidan-casey

@aidan-casey This PR does that, if transform_null_to_optional is enabled (which it is not by default). The maintainers of laravel-data do not want this to be the default behavior, see https://github.com/spatie/laravel-data/pull/119 for details.

innocenzi avatar Jul 27 '22 17:07 innocenzi

@innocenzi - Sorry, I phrased that in a confusing way. My question is more: if a property is nullable in PHP, why would it not just be nullable in Typescript as well? In Typescript and PHP null <> optional.

aidan-casey avatar Jul 27 '22 18:07 aidan-casey

I explained it in the discussion of the PR linked, but here's another explanation.

In PHP, there's no such thing as "optional". TypeScript is different; there is null and undefined.

The difference is that null means that a variable has been assigned an empty value (null), while undefined means a variable has not been assigned any value (though you can assign undefined to a variable).

Practically speaking, this means you cannot omit nullable properties on an object in TypeScript. This leads to poor DX and require the use of custom generic types to transform nullable properties of a type to optional ones in order to avoid being forced to initialize them all with null, which is both cumbersome and not useful.

The TL;DR is that "nullable" in PHP translates better to "optional" in TypeScript than a null type union.

innocenzi avatar Jul 27 '22 18:07 innocenzi

@innocenzi - My point is that I don't think that is an accurate equivalent though. Marking a property as nullable in PHP does not make it optional. You still have to pass a value. This aligns perfectly with Typescript's nullable as well.

For example:

// This class would require either null OR a string to be passed, but cannot be omitted.
class Test
{
    public function __construct(?string $value)
    {}
}

The only time in which this package will automatically set to null is when building from request data outside the construct. We could argue whether or not it should do that, but when using traditional PHP syntax the above would throw an error if nothing was passed.

aidan-casey avatar Jul 27 '22 18:07 aidan-casey

I know and understand that, however my points regarding DX still stand. If you want, I can give you code snippets tomorrow that demonstrate how annoying it is to work with nullable properties in a TypeScript interface.

Working with PHP and working with TypeScript are two different things. The type system is different, and we don't write the same kind of code (back-end logic and front-end manipulation). My point there is that it is not right nor practical to try to directly translate one language's way of working to another. The goal is to make them interoperate nicely, in a way that makes sense.

Please keep in mind that this PR brings this functionality as an option, disabled by default. If you don't want this behavior, you don't have to deal with it (though I'm really not sure why one would like to deal with all null union types in interfaces with potentially dozens of properties).

innocenzi avatar Jul 27 '22 18:07 innocenzi

Just a heads up that upstream PRs are in a ready-to-be-reviewed state, they provide value without adding too much code, and if I can help in any way to make this move forward you can let me know. 😊

Sorry for pushing, I know that you are busy - just making sure this doesn't fall into oblivion.

innocenzi avatar Sep 07 '22 18:09 innocenzi

While I understand it makes sense from a practical point of view, I can't help but shake the feeling that it's "incorrect" :)

I get that adding #[Optional] to every property is a hassle, would and #[OptionalProperties] attribute to declare all properties of the class optional more ergonomic? That'd give the developer more control than a global assumption.

Another idea is to mark properties that have an explicit null value as default as optional. Also not 100% correct, but it aligns well with the idea of "these properties don't need to be provided explicitly".

class User extends Data
{
    public function __construct(
        public string $email,
        public ?string $name,
        public ?int $age = null,
    ) {}
}
interface User {
  email: string;
  name: string | null;
  age?: number;
}

sebastiandedeyne avatar Sep 08 '22 09:09 sebastiandedeyne

Hey @sebastiandedeyne, thanks for the answer! Can you elaborate on your feeling that it's incorrect? Maybe I can clarify somehow why I think it's actually the correct behavior.

I think that you feel this is incorrect because with that option enabled, you would have no way to mark a property as nullable instead of optional? Or is it because you definitely feel like a nullable property in PHP translates well into a nullable property in TypeScript?

That'd give the developer more control than a global assumption.

We can always offer an escape hatch with something like a #[Nullable] attribute:

class User extends Data
{
    public function __construct(
        public string $email,
        public ?string $name,
        #[Nullable]
        public ?int $age,
    ) {}
}
interface User {
  email: string;
  name?: string;
  age: number | null;
}

But I really think that would just solve an edge-case - this is never something I needed or wanted to do, personally. I avoid null as much as I can in TypeScript, there's only a few cases where it's needed.

That being said, among your suggested alternative, my favorite would be the #[OptionalProperties] attribute assigned to the Data Object.

innocenzi avatar Sep 08 '22 09:09 innocenzi

Hey, I wanted to request the same feature as added here - an Optional attribute instead of the Optional object I have to type and assign. Is there any ETA for this PR and a corresponding release?

Right now we have DTOs like the following and it just feels wrong to type a package class to make it optional if the reality is that this property should be null if directly accessed and removed if transformed to array. As also in application code doing $dto->id instanceof Optional instead of $dto->id === null feels extremely wrong.

final class Transaction extends Data
{
    public function __construct(
        public readonly Optional|int $id = new Optional(),
        public readonly Optional|string $code = new Optional(),
        public readonly Optional|int $companyId = new Optional(),
    ) {}
}

Gummibeer avatar Nov 11 '22 14:11 Gummibeer

For the record, this PR needs to be merged first, then this PR, and finally this one.

innocenzi avatar Nov 12 '22 23:11 innocenzi

Laravel Data

Ruben & I discussed this today. Here's what we've decided for now:

We're going to add support for your proposed Optional attribute.

class User extends Data
{
    public function __construct(
        public string $email,
        public ?string $name,
	    #[Optional]
        public int $age,
    ) {}
}
type User = {
    email: string;
    name: string | null;
    age?: int;
}

When using Laravel Data's Optional type, TypeScript transformer will behave the same.

class User extends Data
{
    public function __construct(
        public string $email,
        public ?string $name,
        public int|Optional $age,
    ) {}
}

We're going to hold off on a global flag to change the behavior of undefined properties in TypeScript. Instead, we'll allow Optional to be used on the entire class.

#[Optional]
class User extends Data
{
    public function __construct(
        public string $email,
        public ?string $name,
        public int $age,
    ) {}
}
type User = {
    email?: string;
    name?: string | null;
    age?: int;
}

We're holding off on the rest because in the coming months we're planning to experiment wit data objects setups with optional properties and more, and don't want to rush in with an API yet.

So you can expect more improvements in the future, but we'll need some more time :)

Ruben is currently updating this PR to match the above, should be merged soon!

sebastiandedeyne avatar Nov 18 '22 14:11 sebastiandedeyne

Fantastic news! Thank you very much for advancing on this topic. Looking forward to what you'll come up with regarding your experiments, and in the meantime the class attribute will be muuuch better than my current workarounds.

Let me know if I can be of any help!

innocenzi avatar Nov 18 '22 15:11 innocenzi

Merged, thanks all!

rubenvanassche avatar Nov 18 '22 16:11 rubenvanassche

Thank you Ruben!

innocenzi avatar Nov 18 '22 16:11 innocenzi

Thanks all! 🙏

Gummibeer avatar Nov 18 '22 16:11 Gummibeer

I just realized, when trying to upgrade to this PR from my workaround, that the #[Optional] attribute makes all attributes optional, instead of just nullable ones... which really is not useful in the scenario I was trying to solve with this PR in the first place. :(


Example in the wild: I really don't want null in my types. It simply has poor compatibility with the front-end world.

image

innocenzi avatar Dec 02 '22 00:12 innocenzi

We're holding off on the rest because in the coming months we're planning to experiment wit data objects setups with optional properties and more, and don't want to rush in with an API yet.

Hey, did you guys had time for this yet? I'd love to help — currently, #[Optional] on a class fixes an issue but creates another one by making type-checking useless for required properties :|

I still think my original proposal is a good compromise and I don't think a breaking change would be bad if you find a better solution later.

innocenzi avatar Feb 10 '23 16:02 innocenzi