diff --git a/docs/usage/annotations.md b/docs/usage/annotations.md index 2d555ba1..6a7c9c44 100644 --- a/docs/usage/annotations.md +++ b/docs/usage/annotations.md @@ -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: diff --git a/src/Attributes/NullableNotOptional.php b/src/Attributes/NullableNotOptional.php new file mode 100644 index 00000000..48895f77 --- /dev/null +++ b/src/Attributes/NullableNotOptional.php @@ -0,0 +1,10 @@ +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() ); diff --git a/src/TypeReflectors/TypeReflector.php b/src/TypeReflectors/TypeReflector.php index 7f16e80a..5f1b53b8 100644 --- a/src/TypeReflectors/TypeReflector.php +++ b/src/TypeReflectors/TypeReflector.php @@ -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 @@ -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); } } diff --git a/tests/Transformers/DtoTransformerTest.php b/tests/Transformers/DtoTransformerTest.php index 5ba756b6..51d2d432 100644 --- a/tests/Transformers/DtoTransformerTest.php +++ b/tests/Transformers/DtoTransformerTest.php @@ -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; @@ -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')] + 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); +}); \ No newline at end of file diff --git a/tests/__snapshots__/DtoTransformerTest__it_class_level_nullable_attribute_affects_all_nullable_properties__1.txt b/tests/__snapshots__/DtoTransformerTest__it_class_level_nullable_attribute_affects_all_nullable_properties__1.txt new file mode 100644 index 00000000..164a3288 --- /dev/null +++ b/tests/__snapshots__/DtoTransformerTest__it_class_level_nullable_attribute_affects_all_nullable_properties__1.txt @@ -0,0 +1,4 @@ +{ +name: string | null; +age: number | null; +} \ No newline at end of file diff --git a/tests/__snapshots__/DtoTransformerTest__it_class_level_nullable_attribute_with_property_literal_typescript_type_adds_null__1.txt b/tests/__snapshots__/DtoTransformerTest__it_class_level_nullable_attribute_with_property_literal_typescript_type_adds_null__1.txt new file mode 100644 index 00000000..ca71999b --- /dev/null +++ b/tests/__snapshots__/DtoTransformerTest__it_class_level_nullable_attribute_with_property_literal_typescript_type_adds_null__1.txt @@ -0,0 +1,3 @@ +{ +prop: CustomType | null; +} \ No newline at end of file diff --git a/tests/__snapshots__/DtoTransformerTest__it_nullable_attribute_on_non-nullable_property_has_no_effect__1.txt b/tests/__snapshots__/DtoTransformerTest__it_nullable_attribute_on_non-nullable_property_has_no_effect__1.txt new file mode 100644 index 00000000..928e0b67 --- /dev/null +++ b/tests/__snapshots__/DtoTransformerTest__it_nullable_attribute_on_non-nullable_property_has_no_effect__1.txt @@ -0,0 +1,3 @@ +{ +prop: string; +} \ No newline at end of file diff --git a/tests/__snapshots__/DtoTransformerTest__it_nullable_attribute_overrides_nullToOptional_config__1.txt b/tests/__snapshots__/DtoTransformerTest__it_nullable_attribute_overrides_nullToOptional_config__1.txt new file mode 100644 index 00000000..758819d1 --- /dev/null +++ b/tests/__snapshots__/DtoTransformerTest__it_nullable_attribute_overrides_nullToOptional_config__1.txt @@ -0,0 +1,3 @@ +{ +settings: Array | null; +} \ No newline at end of file diff --git a/tests/__snapshots__/DtoTransformerTest__it_nullable_attribute_with_complex_typescript_literal_type_adds_null__1.txt b/tests/__snapshots__/DtoTransformerTest__it_nullable_attribute_with_complex_typescript_literal_type_adds_null__1.txt new file mode 100644 index 00000000..492d9512 --- /dev/null +++ b/tests/__snapshots__/DtoTransformerTest__it_nullable_attribute_with_complex_typescript_literal_type_adds_null__1.txt @@ -0,0 +1,3 @@ +{ +prop: Array | null; +} \ No newline at end of file diff --git a/tests/__snapshots__/DtoTransformerTest__it_nullable_attribute_with_literal_type_already_containing_null_does_not_duplicate_null__1.txt b/tests/__snapshots__/DtoTransformerTest__it_nullable_attribute_with_literal_type_already_containing_null_does_not_duplicate_null__1.txt new file mode 100644 index 00000000..1b9e8a9c --- /dev/null +++ b/tests/__snapshots__/DtoTransformerTest__it_nullable_attribute_with_literal_type_already_containing_null_does_not_duplicate_null__1.txt @@ -0,0 +1,3 @@ +{ +prop: string | null; +} \ No newline at end of file diff --git a/tests/__snapshots__/DtoTransformerTest__it_nullable_attribute_with_literal_typescript_type_uses_literal_type_and_is_not_optional__1.txt b/tests/__snapshots__/DtoTransformerTest__it_nullable_attribute_with_literal_typescript_type_uses_literal_type_and_is_not_optional__1.txt new file mode 100644 index 00000000..c44b2d42 --- /dev/null +++ b/tests/__snapshots__/DtoTransformerTest__it_nullable_attribute_with_literal_typescript_type_uses_literal_type_and_is_not_optional__1.txt @@ -0,0 +1,3 @@ +{ +prop: string | CustomType | null; +} \ No newline at end of file diff --git a/tests/__snapshots__/DtoTransformerTest__it_nullable_attribute_with_non-nullable_property_and_literal_type_does_not_add_null__1.txt b/tests/__snapshots__/DtoTransformerTest__it_nullable_attribute_with_non-nullable_property_and_literal_type_does_not_add_null__1.txt new file mode 100644 index 00000000..222b221d --- /dev/null +++ b/tests/__snapshots__/DtoTransformerTest__it_nullable_attribute_with_non-nullable_property_and_literal_type_does_not_add_null__1.txt @@ -0,0 +1,3 @@ +{ +prop: CustomType; +} \ No newline at end of file diff --git a/tests/__snapshots__/DtoTransformerTest__it_nullable_attribute_with_simple_literal_typescript_type_adds_null_to_type__1.txt b/tests/__snapshots__/DtoTransformerTest__it_nullable_attribute_with_simple_literal_typescript_type_adds_null_to_type__1.txt new file mode 100644 index 00000000..ca71999b --- /dev/null +++ b/tests/__snapshots__/DtoTransformerTest__it_nullable_attribute_with_simple_literal_typescript_type_adds_null_to_type__1.txt @@ -0,0 +1,3 @@ +{ +prop: CustomType | null; +} \ No newline at end of file diff --git a/tests/__snapshots__/DtoTransformerTest__it_nullable_attribute_with_struct_literal_typescript_type_adds_null_to_type__1.txt b/tests/__snapshots__/DtoTransformerTest__it_nullable_attribute_with_struct_literal_typescript_type_adds_null_to_type__1.txt new file mode 100644 index 00000000..fdbd3006 --- /dev/null +++ b/tests/__snapshots__/DtoTransformerTest__it_nullable_attribute_with_struct_literal_typescript_type_adds_null_to_type__1.txt @@ -0,0 +1,3 @@ +{ +prop: {foo:string;bar:number;} | null; +} \ No newline at end of file diff --git a/tests/__snapshots__/DtoTransformerTest__it_nullable_attribute_with_struct_literal_typescript_type_adds_null_to_type_when_struct_has_a_null__1.txt b/tests/__snapshots__/DtoTransformerTest__it_nullable_attribute_with_struct_literal_typescript_type_adds_null_to_type_when_struct_has_a_null__1.txt new file mode 100644 index 00000000..bbdc912b --- /dev/null +++ b/tests/__snapshots__/DtoTransformerTest__it_nullable_attribute_with_struct_literal_typescript_type_adds_null_to_type_when_struct_has_a_null__1.txt @@ -0,0 +1,3 @@ +{ +prop: {foo:string | null;bar:number;} | null; +} \ No newline at end of file diff --git a/tests/__snapshots__/DtoTransformerTest__it_optional_and_nullable_attributes_with_literal_type_create_optional_property_with_null_in_type__1.txt b/tests/__snapshots__/DtoTransformerTest__it_optional_and_nullable_attributes_with_literal_type_create_optional_property_with_null_in_type__1.txt new file mode 100644 index 00000000..43794be5 --- /dev/null +++ b/tests/__snapshots__/DtoTransformerTest__it_optional_and_nullable_attributes_with_literal_type_create_optional_property_with_null_in_type__1.txt @@ -0,0 +1,3 @@ +{ +prop?: CustomType | null; +} \ No newline at end of file diff --git a/tests/__snapshots__/DtoTransformerTest__it_properties_without_nullable_attribute_respect_config__1.txt b/tests/__snapshots__/DtoTransformerTest__it_properties_without_nullable_attribute_respect_config__1.txt new file mode 100644 index 00000000..beea9b79 --- /dev/null +++ b/tests/__snapshots__/DtoTransformerTest__it_properties_without_nullable_attribute_respect_config__1.txt @@ -0,0 +1,4 @@ +{ +withAttribute: string | null; +withoutAttribute?: string; +} \ No newline at end of file diff --git a/tests/__snapshots__/DtoTransformerTest__it_transforms_property_with_nullable_attribute_to_union_type__1.txt b/tests/__snapshots__/DtoTransformerTest__it_transforms_property_with_nullable_attribute_to_union_type__1.txt new file mode 100644 index 00000000..1b9e8a9c --- /dev/null +++ b/tests/__snapshots__/DtoTransformerTest__it_transforms_property_with_nullable_attribute_to_union_type__1.txt @@ -0,0 +1,3 @@ +{ +prop: string | null; +} \ No newline at end of file