Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions docs/usage/annotations.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,65 @@ export type FleetData = {
}
```

## Nullable types

By default, nullable PHP properties are transformed to optional TypeScript properties. For example, `public ?string $name` becomes `name?: string`. However, sometimes you want to explicitly require a property that can be `null`, using TypeScript's union type syntax instead.

You can use the `#[NullableNotOptional]` attribute to transform nullable properties to union types:

```php
use Spatie\TypeScriptTransformer\Attributes\NullableNotOptional;

class DataObject extends Data
{
public function __construct(
#[NullableNotOptional]
public ?string $name,
public int $id,
)
{
}
}
```

This will be transformed into:

```tsx
{
name : string | null;
id : number;
}
```

You can also transform all nullable properties in a class to union types by adding the attribute to the class:

```php
#[NullableNotOptional]
class DataObject extends Data
{
public function __construct(
public ?string $name,
public ?int $age,
public string $email,
)
{
}
}
```

Now all nullable properties will use union type syntax:

```tsx
{
name : string | null;
age : number | null;
email : string;
}
```

**Note:** The `#[NullableNotOptional]` attribute overrides the `transform_null_to_optional` configuration setting. It has no effect on non-nullable properties.


## Selecting a transformer

Want to define a specific transformer for the file? You can use the following annotation:
Expand Down
10 changes: 10 additions & 0 deletions src/Attributes/NullableNotOptional.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace Spatie\TypeScriptTransformer\Attributes;

use Attribute;

#[Attribute]
class NullableNotOptional
{
}
11 changes: 8 additions & 3 deletions src/Transformers/DtoTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use ReflectionClass;
use ReflectionProperty;
use Spatie\TypeScriptTransformer\Attributes\Hidden;
use Spatie\TypeScriptTransformer\Attributes\NullableNotOptional;
use Spatie\TypeScriptTransformer\Attributes\Optional;
use Spatie\TypeScriptTransformer\Structures\MissingSymbolsCollection;
use Spatie\TypeScriptTransformer\Structures\TransformedType;
Expand Down Expand Up @@ -55,25 +56,29 @@ protected function transformProperties(
MissingSymbolsCollection $missingSymbols
): string {
$isClassOptional = ! empty($class->getAttributes(Optional::class));
$isClassNullable = ! empty($class->getAttributes(NullableNotOptional::class));
$nullablesAreOptional = $this->config->shouldConsiderNullAsOptional();

return array_reduce(
$this->resolveProperties($class),
function (string $carry, ReflectionProperty $property) use ($isClassOptional, $missingSymbols, $nullablesAreOptional) {
function (string $carry, ReflectionProperty $property) use ($isClassOptional, $isClassNullable, $missingSymbols, $nullablesAreOptional) {
$isHidden = ! empty($property->getAttributes(Hidden::class));

if ($isHidden) {
return $carry;
}

$hasNullableAttribute = $isClassNullable || ! empty($property->getAttributes(NullableNotOptional::class));
$propertyNullablesAreOptional = $hasNullableAttribute ? false : $nullablesAreOptional;

$isOptional = $isClassOptional
|| ! empty($property->getAttributes(Optional::class))
|| ($property->getType()?->allowsNull() && $nullablesAreOptional);
|| ($property->getType()?->allowsNull() && $propertyNullablesAreOptional);

$transformed = $this->reflectionToTypeScript(
$property,
$missingSymbols,
$isOptional,
$propertyNullablesAreOptional,
...$this->typeProcessors()
);

Expand Down
11 changes: 10 additions & 1 deletion src/TypeReflectors/TypeReflector.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ public function reflectionFromAttribute(): ?Type
/** @var \Spatie\TypeScriptTransformer\Attributes\TypeScriptTransformableAttribute $attribute */
$attribute = current($attributes)->newInstance();

return $attribute->getType();
return $this->nullifyType($attribute->getType());
}

public function reflectFromDocblock(): ?Type
Expand Down Expand Up @@ -160,6 +160,15 @@ private function nullifyType(Type $type): Type
));
}

// Check if TypeScriptType already contains null in its literal string
if ($type instanceof TypeScriptType) {
$typeString = (string) $type;
// Match null as a standalone type or as part of a union (| null or null |)
if (preg_match('/(?:^|\|)\s*null\s*(?:\||$)/', $typeString)) {
return $type;
}
}

return new Nullable($type);
}
}
213 changes: 213 additions & 0 deletions tests/Transformers/DtoTransformerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use function Spatie\Snapshots\assertMatchesTextSnapshot;
use Spatie\TypeScriptTransformer\Attributes\Hidden;
use Spatie\TypeScriptTransformer\Attributes\LiteralTypeScriptType;
use Spatie\TypeScriptTransformer\Attributes\NullableNotOptional;
use Spatie\TypeScriptTransformer\Attributes\Optional;
use Spatie\TypeScriptTransformer\Attributes\TypeScriptType;
use Spatie\TypeScriptTransformer\Structures\MissingSymbolsCollection;
Expand Down Expand Up @@ -161,3 +162,215 @@ class DummyOptionalDto

$this->assertMatchesSnapshot($type->transformed);
});

it('transforms property with nullable attribute to union type', function () {
$class = new class() {
#[NullableNotOptional]
public ?string $prop;
};

$type = $this->transformer->transform(
new ReflectionClass($class),
'Typed'
);

assertMatchesSnapshot($type->transformed);
});

it('nullable attribute overrides nullToOptional config', function () {
$class = new class() {
#[NullableNotOptional]
public ?array $settings;
};

$config = TypeScriptTransformerConfig::create()->nullToOptional(true);
$type = (new DtoTransformer($config))->transform(
new ReflectionClass($class),
'Typed'
);

assertMatchesSnapshot($type->transformed);
});

it('properties without nullable attribute respect config', function () {
$class = new class() {
#[NullableNotOptional]
public ?string $withAttribute;
public ?string $withoutAttribute;
};

$config = TypeScriptTransformerConfig::create()->nullToOptional(true);
$type = (new DtoTransformer($config))->transform(
new ReflectionClass($class),
'Typed'
);

assertMatchesSnapshot($type->transformed);
});

it('class level nullable attribute affects all nullable properties', function () {
#[NullableNotOptional]
class DummyNullableDto
{
public ?string $name;
public ?int $age;
}

$type = $this->transformer->transform(
new ReflectionClass(DummyNullableDto::class),
'Typed'
);

assertMatchesSnapshot($type->transformed);
});

it('nullable attribute on non-nullable property has no effect', function () {
$class = new class() {
#[NullableNotOptional]
public string $prop;
};

$type = $this->transformer->transform(
new ReflectionClass($class),
'Typed'
);

assertMatchesSnapshot($type->transformed);
});

it('nullable attribute with literal typescript type uses literal type and is not optional', function () {
$class = new class() {
#[NullableNotOptional]
#[LiteralTypeScriptType('string | CustomType')]
public ?string $prop;
};

$type = $this->transformer->transform(
new ReflectionClass($class),
'Typed'
);

assertMatchesSnapshot($type->transformed);
});

it('nullable attribute with simple literal typescript type adds null to type', function () {
$class = new class() {
#[NullableNotOptional]
#[LiteralTypeScriptType('CustomType')]
public ?string $prop;
};

$type = $this->transformer->transform(
new ReflectionClass($class),
'Typed'
);

assertMatchesSnapshot($type->transformed);
});

it('nullable attribute with struct literal typescript type adds null to type', function () {
$class = new class() {
#[NullableNotOptional]
#[LiteralTypeScriptType(['foo' => 'string', 'bar' => 'number'])]
public ?array $prop;
};

$type = $this->transformer->transform(
new ReflectionClass($class),
'Typed'
);

assertMatchesSnapshot($type->transformed);
});

it('class level nullable attribute with property literal typescript type adds null', function () {
#[NullableNotOptional]
class DummyNullableLiteralDto
{
#[LiteralTypeScriptType('CustomType')]
public ?string $prop;
}

$type = $this->transformer->transform(
new ReflectionClass(DummyNullableLiteralDto::class),
'Typed'
);

assertMatchesSnapshot($type->transformed);
});

it('nullable attribute with non-nullable property and literal type does not add null', function () {
$class = new class() {
#[NullableNotOptional]
#[LiteralTypeScriptType('CustomType')]
public string $prop;
};

$type = $this->transformer->transform(
new ReflectionClass($class),
'Typed'
);

assertMatchesSnapshot($type->transformed);
});

it('nullable attribute with literal type already containing null does not duplicate null', function () {
$class = new class() {
#[NullableNotOptional]
#[LiteralTypeScriptType('string | null')]
public ?string $prop;
};

$type = $this->transformer->transform(
new ReflectionClass($class),
'Typed'
);

assertMatchesSnapshot($type->transformed);
});

it('nullable attribute with complex typescript literal type adds null', function () {
$class = new class() {
#[NullableNotOptional]
#[LiteralTypeScriptType('Array<string>')]
public mixed $prop = null;
};

$type = $this->transformer->transform(
new ReflectionClass($class),
'Typed'
);

assertMatchesSnapshot($type->transformed);
});

it('optional and nullable attributes with literal type create optional property with null in type', function () {
$class = new class() {
#[Optional]
#[NullableNotOptional]
#[LiteralTypeScriptType('CustomType')]
public ?string $prop;
};

$type = $this->transformer->transform(
new ReflectionClass($class),
'Typed'
);

assertMatchesSnapshot($type->transformed);
});

it('nullable attribute with struct literal typescript type adds null to type when struct has a null', function () {
$class = new class() {
#[NullableNotOptional]
#[LiteralTypeScriptType(['foo' => 'string | null', 'bar' => 'number'])]
public ?array $prop;
};

$type = $this->transformer->transform(
new ReflectionClass($class),
'Typed'
);

assertMatchesSnapshot($type->transformed);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
name: string | null;
age: number | null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
prop: CustomType | null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
prop: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
settings: Array<any> | null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
prop: Array<string> | null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
prop: string | null;
}
Loading