Skip to content

Lazy|Type properties incorrectly marked as optional in TypeScript (typescript-transformer) #1105

@mez-coretek

Description

@mez-coretek

✏️ Describe the bug
When using Lazy|Type in property type hints with Lazy::closure(), the TypeScript transformer incorrectly marks properties as optional. However, using ClosureLazy|Type works correctly and keeps properties required.

The root cause is in DataTypeScriptTransformer.php line 78, which uses exact string comparison (!==) instead of inheritance checking:

|| ($dataProperty->type->lazyType && $dataProperty->type->lazyType !== ClosureLazy::class)

When you write Lazy|Type, the DataTypeFactory captures Lazy::class. Since "Lazy" !== "ClosureLazy", the property is marked as optional in TypeScript.

↪️ To Reproduce

use Spatie\LaravelData\Data;
use Spatie\LaravelData\Lazy;
use Spatie\LaravelData\Support\Lazy\ClosureLazy;

it('marks Lazy union properties as optional in TypeScript incorrectly', function () {
    class MemberData extends Data
    {
        public function __construct(
            public string $name,
            public string $email,
        ) {}
    }

    // Using abstract Lazy class in type hint
    class ShowDataWithLazy extends Data
    {
        public function __construct(
            public Lazy|MemberData $member,
        ) {}
    }

    // Using concrete ClosureLazy class in type hint
    class ShowDataWithClosureLazy extends Data
    {
        public function __construct(
            public ClosureLazy|MemberData $member,
        ) {}
    }

    // Both use Lazy::closure() at runtime
    $dataWithLazy = ShowDataWithLazy::from([
        'member' => Lazy::closure(fn () => MemberData::from(['name' => 'John', 'email' => '[email protected]'])),
    ]);

    $dataWithClosureLazy = ShowDataWithClosureLazy::from([
        'member' => Lazy::closure(fn () => MemberData::from(['name' => 'John', 'email' => '[email protected]'])),
    ]);

    // Generate TypeScript for both
    $transformer = app(\Spatie\LaravelData\Support\TypeScriptTransformer\DataTypeScriptTransformer::class);
    
    // ShowDataWithLazy will have member?: MemberData (optional)
    // ShowDataWithClosureLazy will have member: MemberData (required)
    
    dd([
        'with_Lazy_generates_optional' => 'member?: MemberData',
        'with_ClosureLazy_generates_required' => 'member: MemberData',
    ]);
});

Generated TypeScript output:

With Lazy|Type:

export type ShowDataWithLazy = {
    member?: MemberData;  // ❌ Optional when it shouldn't be
};

With ClosureLazy|Type:

export type ShowDataWithClosureLazy = {
    member: MemberData;  // ✅ Required as expected
};

✅ Expected behavior
Properties using Lazy::closure() should NOT be marked as optional in TypeScript, regardless of whether the type hint is Lazy|Type or ClosureLazy|Type, since ClosureLazy is specifically excluded from the optional check.

Both classes should generate the same required TypeScript property:

member: MemberData;  // Required, not optional

Suggested fix:
Change the check in DataTypeScriptTransformer.php line 78 from:

|| ($dataProperty->type->lazyType && $dataProperty->type->lazyType !== ClosureLazy::class)

To:

|| ($dataProperty->type->lazyType && !is_a($dataProperty->type->lazyType, ClosureLazy::class, true))

This would properly check if lazyType is ClosureLazy or a subclass, rather than requiring exact equality.

🖥️ Versions

Laravel: 12.x
Laravel Data: 4.15.1 (also affects 4.18.0)
PHP: 8.3.23

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions