From cead53af84febc94c685241229ae3c344013b287 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Sat, 17 Jan 2026 19:35:42 +0100 Subject: [PATCH 1/9] feat(reflection): add enum reflector --- packages/reflection/src/EnumReflector.php | 135 +++++++++ packages/reflection/src/TypeReflector.php | 5 + .../reflection/tests/EnumReflectorTest.php | 262 ++++++++++++++++++ 3 files changed, 402 insertions(+) create mode 100644 packages/reflection/src/EnumReflector.php create mode 100644 packages/reflection/tests/EnumReflectorTest.php diff --git a/packages/reflection/src/EnumReflector.php b/packages/reflection/src/EnumReflector.php new file mode 100644 index 000000000..bb59cf20a --- /dev/null +++ b/packages/reflection/src/EnumReflector.php @@ -0,0 +1,135 @@ +|TEnumName|PHPReflectionEnum $reflectionEnum + */ + public function __construct(string|object $reflectionEnum) + { + if (is_string($reflectionEnum)) { + $reflectionEnum = new PHPReflectionEnum($reflectionEnum); + } elseif ($reflectionEnum instanceof self) { + $reflectionEnum = $reflectionEnum->getReflection(); + } elseif (! $reflectionEnum instanceof PHPReflectionEnum) { + $reflectionEnum = new PHPReflectionEnum($reflectionEnum); + } + + $this->reflectionEnum = $reflectionEnum; + } + + public function getReflection(): PHPReflectionEnum + { + return $this->reflectionEnum; + } + + /** + * @return class-string + */ + public function getName(): string + { + return $this->reflectionEnum->getName(); + } + + public function getShortName(): string + { + return $this->reflectionEnum->getShortName(); + } + + public function getFileName(): string|false + { + return $this->reflectionEnum->getFileName(); + } + + public function getType(): TypeReflector + { + return new TypeReflector($this->reflectionEnum); + } + + public function isBacked(): bool + { + return $this->reflectionEnum->isBacked(); + } + + public function getBackingType(): ?TypeReflector + { + $backingType = $this->reflectionEnum->getBackingType(); + + if ($backingType === null) { + return null; + } + + return new TypeReflector($backingType); + } + + /** + * @return UnitEnum[] + */ + public function getCases(): array + { + return $this->memoize( + key: 'cases', + closure: fn () => array_map( + callback: fn (ReflectionEnumUnitCase $case) => $case->getValue(), + array: $this->reflectionEnum->getCases(), + ), + ); + } + + public function hasCase(string $name): bool + { + return $this->reflectionEnum->hasCase($name); + } + + public function getCase(string $name): UnitEnum + { + return $this->reflectionEnum->getCase($name)->getValue(); + } + + public function is(string $className): bool + { + return $this->getType()->matches($className); + } + + public function implements(string $interface): bool + { + return $this->getType()->matches($interface); + } + + private function memoize(string $key, Closure $closure): mixed + { + if (! array_key_exists($key, $this->memoize)) { + $this->memoize[$key] = $closure(); + } + + return $this->memoize[$key]; + } + + public function __serialize(): array + { + return ['name' => $this->getName()]; + } + + public function __unserialize(array $data): void + { + $this->reflectionEnum = new PHPReflectionEnum($data['name']); + } +} diff --git a/packages/reflection/src/TypeReflector.php b/packages/reflection/src/TypeReflector.php index 64429ba19..e3813c30f 100644 --- a/packages/reflection/src/TypeReflector.php +++ b/packages/reflection/src/TypeReflector.php @@ -65,6 +65,11 @@ public function asClass(): ClassReflector return new ClassReflector($this->cleanDefinition); } + public function asEnum(): EnumReflector + { + return new EnumReflector($this->cleanDefinition); + } + public function equals(string|TypeReflector $type): bool { if (is_string($type)) { diff --git a/packages/reflection/tests/EnumReflectorTest.php b/packages/reflection/tests/EnumReflectorTest.php new file mode 100644 index 000000000..398da38d1 --- /dev/null +++ b/packages/reflection/tests/EnumReflectorTest.php @@ -0,0 +1,262 @@ +assertEquals(new ReflectionEnum(TestUnitEnum::class), $reflector->getReflection()); + } + + #[Test] + public function getting_name(): void + { + $reflector = new EnumReflector(TestUnitEnum::class); + $reflection = new ReflectionEnum(TestUnitEnum::class); + + $this->assertSame($reflector->getName(), $reflection->getName()); + } + + #[Test] + public function getting_short_name(): void + { + $reflector = new EnumReflector(TestUnitEnum::class); + $reflection = new ReflectionEnum(TestUnitEnum::class); + + $this->assertSame($reflector->getShortName(), $reflection->getShortName()); + } + + #[Test] + public function getting_file_name(): void + { + $reflector = new EnumReflector(TestUnitEnum::class); + + $this->assertNotFalse($reflector->getFileName()); + $this->assertStringEndsWith('EnumReflectorTest.php', $reflector->getFileName()); + } + + #[Test] + public function getting_type(): void + { + $reflector = new EnumReflector(TestBackedEnum::class); + $type = $reflector->getType(); + + $this->assertInstanceOf(TypeReflector::class, $type); + $this->assertSame(TestBackedEnum::class, $type->getName()); + } + + #[Test] + public function is_backed_for_unit_enum(): void + { + $reflector = new EnumReflector(TestUnitEnum::class); + + $this->assertFalse($reflector->isBacked()); + } + + #[Test] + public function is_backed_for_backed_enum(): void + { + $reflector = new EnumReflector(TestBackedEnum::class); + + $this->assertTrue($reflector->isBacked()); + } + + #[Test] + public function get_backing_type_for_unit_enum(): void + { + $reflector = new EnumReflector(TestUnitEnum::class); + + $this->assertNull($reflector->getBackingType()); + } + + #[Test] + public function get_backing_type_for_backed_enum(): void + { + $reflector = new EnumReflector(TestBackedEnum::class); + $backingType = $reflector->getBackingType(); + + $this->assertInstanceOf(TypeReflector::class, $backingType); + $this->assertSame('string', $backingType->getName()); + } + + #[Test] + public function get_backing_type_for_int_backed_enum(): void + { + $reflector = new EnumReflector(TestIntBackedEnum::class); + $backingType = $reflector->getBackingType(); + + $this->assertInstanceOf(TypeReflector::class, $backingType); + $this->assertSame('int', $backingType->getName()); + } + + #[Test] + public function get_cases(): void + { + $reflector = new EnumReflector(TestUnitEnum::class); + $cases = $reflector->getCases(); + + $this->assertCount(3, $cases); + $this->assertSame(TestUnitEnum::ONE, $cases[0]); + $this->assertSame(TestUnitEnum::TWO, $cases[1]); + $this->assertSame(TestUnitEnum::THREE, $cases[2]); + } + + #[Test] + public function get_cases_for_backed_enum(): void + { + $reflector = new EnumReflector(TestBackedEnum::class); + $cases = $reflector->getCases(); + + $this->assertCount(2, $cases); + $this->assertSame(TestBackedEnum::ACTIVE, $cases[0]); + $this->assertSame(TestBackedEnum::INACTIVE, $cases[1]); + } + + #[Test] + public function has_case(): void + { + $reflector = new EnumReflector(TestUnitEnum::class); + + $this->assertTrue($reflector->hasCase('ONE')); + $this->assertTrue($reflector->hasCase('TWO')); + $this->assertTrue($reflector->hasCase('THREE')); + $this->assertFalse($reflector->hasCase('FOUR')); + } + + #[Test] + public function get_case(): void + { + $reflector = new EnumReflector(TestUnitEnum::class); + + $this->assertSame(TestUnitEnum::ONE, $reflector->getCase('ONE')); + $this->assertSame(TestUnitEnum::TWO, $reflector->getCase('TWO')); + $this->assertSame(TestUnitEnum::THREE, $reflector->getCase('THREE')); + } + + #[Test] + public function is_method(): void + { + $reflector = new EnumReflector(TestBackedEnum::class); + + $this->assertTrue($reflector->is(\BackedEnum::class)); + $this->assertTrue($reflector->is(\UnitEnum::class)); + $this->assertFalse($reflector->is(\stdClass::class)); + } + + #[Test] + public function implements_method(): void + { + $reflector = new EnumReflector(TestEnumWithInterface::class); + + $this->assertTrue($reflector->implements(TestInterface::class)); + $this->assertFalse($reflector->implements(\JsonSerializable::class)); + } + + #[Test] + public function serialize(): void + { + $reflector = new EnumReflector(TestUnitEnum::class); + + $serialized = serialize($reflector); + $unserialized = unserialize($serialized); + + $this->assertEquals($reflector, $unserialized); + } + + #[Test] + public function from_type_reflector(): void + { + $typeReflector = new TypeReflector(TestBackedEnum::class); + $enumReflector = $typeReflector->asEnum(); + + $this->assertInstanceOf(EnumReflector::class, $enumReflector); + $this->assertSame(TestBackedEnum::class, $enumReflector->getName()); + $this->assertTrue($enumReflector->isBacked()); + } + + #[Test] + public function constructor_with_enum_instance(): void + { + $reflector = new EnumReflector(TestUnitEnum::ONE); + + $this->assertSame(TestUnitEnum::class, $reflector->getName()); + } + + #[Test] + public function constructor_with_enum_reflector(): void + { + $reflector1 = new EnumReflector(TestUnitEnum::class); + $reflector2 = new EnumReflector($reflector1); + + $this->assertEquals($reflector1, $reflector2); + } + + #[Test] + public function attribute_support(): void + { + $reflector = new EnumReflector(TestEnumWithAttribute::class); + + $this->assertTrue($reflector->hasAttribute(TestAttribute::class)); + $attribute = $reflector->getAttribute(TestAttribute::class); + $this->assertInstanceOf(TestAttribute::class, $attribute); + $this->assertSame('test-value', $attribute->value); + } +} + +enum TestUnitEnum +{ + case ONE; + case TWO; + case THREE; +} + +enum TestBackedEnum: string +{ + case ACTIVE = 'active'; + case INACTIVE = 'inactive'; +} + +enum TestIntBackedEnum: int +{ + case FIRST = 1; + case SECOND = 2; +} + +interface TestInterface +{ +} + +enum TestEnumWithInterface: string implements TestInterface +{ + case VALUE = 'value'; +} + +#[\Attribute] +class TestAttribute +{ + public function __construct( + public string $value, + ) {} +} + +#[TestAttribute('test-value')] +enum TestEnumWithAttribute +{ + case OPTION_A; + case OPTION_B; +} From 9b3ff0ad6efa29a5c2e1714d11a927e5c65454b3 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Sun, 18 Jan 2026 00:34:30 +0100 Subject: [PATCH 2/9] feat(generation): add support for TypeScript types generation --- .../src/TypeScript/GenerateTypesCommand.php | 45 ++++ .../TypeScript/GenericTypeScriptGenerator.php | 62 ++++++ .../src/TypeScript/InterfaceDefinition.php | 33 +++ .../src/TypeScript/PropertyDefinition.php | 23 +++ .../src/TypeScript/ResolvedType.php | 20 ++ .../src/TypeScript/StructureResolver.php | 15 ++ .../ClassStructureResolver.php | 118 +++++++++++ .../EnumStructureResolver.php | 42 ++++ .../src/TypeScript/TypeDefinition.php | 32 +++ .../TypeScript/TypeDefinitionGenerator.php | 21 ++ .../src/TypeScript/TypeResolver.php | 23 +++ .../src/TypeScript/TypeResolverDiscovery.php | 45 ++++ .../ClassReferenceTypeResolver.php | 33 +++ .../TypeResolvers/DateTimeTypeResolver.php | 27 +++ .../EnumReferenceTypeResolver.php | 33 +++ .../TypeResolvers/MixedTypeResolver.php | 28 +++ .../TypeResolvers/ScalarTypeResolver.php | 29 +++ .../generation/src/TypeScript/TypeScript.php | 13 ++ .../TypeScript/TypeScriptGenerationConfig.php | 37 ++++ .../src/TypeScript/TypeScriptGenerator.php | 21 ++ .../TypeScriptGeneratorInitializer.php | 20 ++ .../src/TypeScript/TypeScriptOutput.php | 64 ++++++ .../src/TypeScript/TypeScriptWriter.php | 17 ++ .../src/TypeScript/TypeSourceDiscovery.php | 42 ++++ .../src/TypeScript/TypesRepository.php | 55 +++++ .../DirectoryTypeScriptGenerationConfig.php | 29 +++ .../TypeScript/Writers/DirectoryWriter.php | 193 ++++++++++++++++++ .../Writers/NamespacedFileWriter.php | 145 +++++++++++++ .../NamespacedTypeScriptGenerationConfig.php | 29 +++ .../src/TypeScript/typescript.config.php | 9 + .../Fixtures/Security/Permission.php | 10 + .../TypeScript/Fixtures/Security/Role.php | 12 ++ .../tests/TypeScript/Fixtures/Settings.php | 11 + .../tests/TypeScript/Fixtures/Theme.php | 9 + .../tests/TypeScript/Fixtures/User.php | 18 ++ .../TypeScript/GenerateTypesCommandTest.php | 84 ++++++++ .../ClassStructureResolverTest.php | 82 ++++++++ .../TypeScript/TypeScriptGenerationTest.php | 86 ++++++++ .../Writers/DirectoryWriterTest.php | 123 +++++++++++ .../Writers/NamespacedFileWriterTest.php | 121 +++++++++++ 40 files changed, 1859 insertions(+) create mode 100644 packages/generation/src/TypeScript/GenerateTypesCommand.php create mode 100644 packages/generation/src/TypeScript/GenericTypeScriptGenerator.php create mode 100644 packages/generation/src/TypeScript/InterfaceDefinition.php create mode 100644 packages/generation/src/TypeScript/PropertyDefinition.php create mode 100644 packages/generation/src/TypeScript/ResolvedType.php create mode 100644 packages/generation/src/TypeScript/StructureResolver.php create mode 100644 packages/generation/src/TypeScript/StructureResolvers/ClassStructureResolver.php create mode 100644 packages/generation/src/TypeScript/StructureResolvers/EnumStructureResolver.php create mode 100644 packages/generation/src/TypeScript/TypeDefinition.php create mode 100644 packages/generation/src/TypeScript/TypeDefinitionGenerator.php create mode 100644 packages/generation/src/TypeScript/TypeResolver.php create mode 100644 packages/generation/src/TypeScript/TypeResolverDiscovery.php create mode 100644 packages/generation/src/TypeScript/TypeResolvers/ClassReferenceTypeResolver.php create mode 100644 packages/generation/src/TypeScript/TypeResolvers/DateTimeTypeResolver.php create mode 100644 packages/generation/src/TypeScript/TypeResolvers/EnumReferenceTypeResolver.php create mode 100644 packages/generation/src/TypeScript/TypeResolvers/MixedTypeResolver.php create mode 100644 packages/generation/src/TypeScript/TypeResolvers/ScalarTypeResolver.php create mode 100644 packages/generation/src/TypeScript/TypeScript.php create mode 100644 packages/generation/src/TypeScript/TypeScriptGenerationConfig.php create mode 100644 packages/generation/src/TypeScript/TypeScriptGenerator.php create mode 100644 packages/generation/src/TypeScript/TypeScriptGeneratorInitializer.php create mode 100644 packages/generation/src/TypeScript/TypeScriptOutput.php create mode 100644 packages/generation/src/TypeScript/TypeScriptWriter.php create mode 100644 packages/generation/src/TypeScript/TypeSourceDiscovery.php create mode 100644 packages/generation/src/TypeScript/TypesRepository.php create mode 100644 packages/generation/src/TypeScript/Writers/DirectoryTypeScriptGenerationConfig.php create mode 100644 packages/generation/src/TypeScript/Writers/DirectoryWriter.php create mode 100644 packages/generation/src/TypeScript/Writers/NamespacedFileWriter.php create mode 100644 packages/generation/src/TypeScript/Writers/NamespacedTypeScriptGenerationConfig.php create mode 100644 packages/generation/src/TypeScript/typescript.config.php create mode 100644 packages/generation/tests/TypeScript/Fixtures/Security/Permission.php create mode 100644 packages/generation/tests/TypeScript/Fixtures/Security/Role.php create mode 100644 packages/generation/tests/TypeScript/Fixtures/Settings.php create mode 100644 packages/generation/tests/TypeScript/Fixtures/Theme.php create mode 100644 packages/generation/tests/TypeScript/Fixtures/User.php create mode 100644 packages/generation/tests/TypeScript/GenerateTypesCommandTest.php create mode 100644 packages/generation/tests/TypeScript/StructureResolvers/ClassStructureResolverTest.php create mode 100644 packages/generation/tests/TypeScript/TypeScriptGenerationTest.php create mode 100644 packages/generation/tests/TypeScript/Writers/DirectoryWriterTest.php create mode 100644 packages/generation/tests/TypeScript/Writers/NamespacedFileWriterTest.php diff --git a/packages/generation/src/TypeScript/GenerateTypesCommand.php b/packages/generation/src/TypeScript/GenerateTypesCommand.php new file mode 100644 index 000000000..b4e83dc06 --- /dev/null +++ b/packages/generation/src/TypeScript/GenerateTypesCommand.php @@ -0,0 +1,45 @@ +console->writeln(); + + $output = $this->generator->generate(); + + if ($output->isEmpty()) { + $this->console->warning('No types found to generate.'); + return; + } + + $writer = $this->container->get($this->config->writer); + $writer->write($output); + + $this->console->success(sprintf( + 'Generated %d type definitions across %d namespaces.', + count($output->getAllDefinitions()), + count($output->getNamespaces()), + )); + } +} diff --git a/packages/generation/src/TypeScript/GenericTypeScriptGenerator.php b/packages/generation/src/TypeScript/GenericTypeScriptGenerator.php new file mode 100644 index 000000000..993eb5866 --- /dev/null +++ b/packages/generation/src/TypeScript/GenericTypeScriptGenerator.php @@ -0,0 +1,62 @@ +repository = new TypesRepository(); + + foreach ($this->config->sources as $className) { + $this->include($className); + } + + $grouped = []; + + foreach ($this->repository->getAll() as $definition) { + $namespace = $definition->namespace; + $grouped[$namespace] ??= []; + $grouped[$namespace][] = $definition; + } + + ksort($grouped); + + return new TypeScriptOutput( + namespaces: $grouped, + ); + } + + public function include(string $className): void + { + if ($this->repository->has($className)) { + return; + } + + $type = new TypeReflector($className); + + if ($type->isEnum()) { + $this->repository->add($this->enumResolver->resolve($type, $this)); + return; + } + + if ($type->isClass() || $type->isInterface()) { + $this->repository->add($this->classResolver->resolve($type, $this)); + return; + } + } +} diff --git a/packages/generation/src/TypeScript/InterfaceDefinition.php b/packages/generation/src/TypeScript/InterfaceDefinition.php new file mode 100644 index 000000000..0abb35162 --- /dev/null +++ b/packages/generation/src/TypeScript/InterfaceDefinition.php @@ -0,0 +1,33 @@ +class, '\\')) { + return ''; + } + + return Str\before_last($this->class, '\\'); + } + } + + /** + * @param PropertyDefinition[] $properties + */ + public function __construct( + public string $class, + public TypeReflector $originalType, + public array $properties, + ) {} +} diff --git a/packages/generation/src/TypeScript/PropertyDefinition.php b/packages/generation/src/TypeScript/PropertyDefinition.php new file mode 100644 index 000000000..a6ad3feb8 --- /dev/null +++ b/packages/generation/src/TypeScript/PropertyDefinition.php @@ -0,0 +1,23 @@ +asClass(); + $properties = []; + + foreach ($class->getPublicProperties() as $property) { + $properties[] = $this->resolveProperty($property, $generator); + } + + return new InterfaceDefinition( + class: $type->getName(), + originalType: $type, + properties: $properties, + ); + } + + private function resolveProperty(PropertyReflector $property, TypeScriptGenerator $generator): PropertyDefinition + { + $type = $property->getType(); + + if ($type->isIterable()) { + $elementTypeReflector = $property->getIterableType(); + + if ($elementTypeReflector !== null) { + $result = $this->resolveType($elementTypeReflector, $generator); + + return new PropertyDefinition( + name: $property->getName(), + definition: $result->type . '[]', + isNullable: $property->isNullable(), + fqcn: $result->fqcn, + ); + } + + return new PropertyDefinition( + name: $property->getName(), + definition: 'any[]', + isNullable: $property->isNullable(), + ); + } + + if ($type->isUnion() || $type->isIntersection()) { + $parts = $type->split(); + $resolvedTypes = []; + $referencedClasses = []; + + foreach ($parts as $part) { + if ($part->getName() === 'null') { + continue; + } + + $result = $this->resolveType($part, $generator); + $resolvedTypes[] = $result->type; + + if ($result->fqcn !== null) { + $referencedClasses[] = $result->fqcn; + } + } + + $symbol = $type->isIntersection() ? '&' : '|'; + + return new PropertyDefinition( + name: $property->getName(), + definition: implode(" {$symbol} ", $resolvedTypes), + isNullable: $property->isNullable(), + fqcn: count($referencedClasses) === 1 ? $referencedClasses[0] : null, + ); + } + + $result = $this->resolveType($type, $generator); + + return new PropertyDefinition( + name: $property->getName(), + definition: $result->type, + isNullable: $property->isNullable(), + fqcn: $result->fqcn, + ); + } + + private function resolveType(TypeReflector $type, TypeScriptGenerator $generator): ResolvedType + { + foreach ($this->config->resolvers as $resolverClass) { + $resolver = $this->container->get($resolverClass); + + if ($resolver->canResolve($type)) { + return $resolver->resolve($type, $generator); + } + } + + return new ResolvedType('any'); + } +} diff --git a/packages/generation/src/TypeScript/StructureResolvers/EnumStructureResolver.php b/packages/generation/src/TypeScript/StructureResolvers/EnumStructureResolver.php new file mode 100644 index 000000000..ec1579d20 --- /dev/null +++ b/packages/generation/src/TypeScript/StructureResolvers/EnumStructureResolver.php @@ -0,0 +1,42 @@ +value + : $case->name; + + return is_string($value) ? "'{$value}'" : $value; + }, + array: $type->asEnum()->getCases(), + ), + ); + + return new TypeDefinition( + class: $type->getName(), + originalType: $type, + definition: $typeScriptType, + isNullable: $type->isNullable(), + ); + } +} diff --git a/packages/generation/src/TypeScript/TypeDefinition.php b/packages/generation/src/TypeScript/TypeDefinition.php new file mode 100644 index 000000000..8f2eed151 --- /dev/null +++ b/packages/generation/src/TypeScript/TypeDefinition.php @@ -0,0 +1,32 @@ +class, '\\')) { + return ''; + } + + return Str\before_last($this->class, '\\'); + } + } + + public function __construct( + public string $class, + public TypeReflector $originalType, + public string $definition, + public bool $isNullable, + ) {} +} diff --git a/packages/generation/src/TypeScript/TypeDefinitionGenerator.php b/packages/generation/src/TypeScript/TypeDefinitionGenerator.php new file mode 100644 index 000000000..6fe0264a3 --- /dev/null +++ b/packages/generation/src/TypeScript/TypeDefinitionGenerator.php @@ -0,0 +1,21 @@ +implements(TypeResolver::class)) { + $this->discoveryItems->add($location, [ + $class->getName(), + $class->getAttribute(Priority::class)->priority ?? Priority::NORMAL, + ]); + } + } + + public function apply(): void + { + // Collect all resolvers with their priorities + $resolvers = []; + foreach ($this->discoveryItems as [$className, $priority]) { + $resolvers[] = ['class' => $className, 'priority' => $priority]; + } + + // Sort by priority (lower values first - framework uses ascending priority) + usort($resolvers, fn ($a, $b) => $a['priority'] <=> $b['priority']); + + // Extract just the class names + $this->config->resolvers = array_column($resolvers, 'class'); + } +} diff --git a/packages/generation/src/TypeScript/TypeResolvers/ClassReferenceTypeResolver.php b/packages/generation/src/TypeScript/TypeResolvers/ClassReferenceTypeResolver.php new file mode 100644 index 000000000..d972951e5 --- /dev/null +++ b/packages/generation/src/TypeScript/TypeResolvers/ClassReferenceTypeResolver.php @@ -0,0 +1,33 @@ +isClass() || $type->isInterface()) && ! $type->isEnum(); + } + + public function resolve(TypeReflector $type, TypeScriptGenerator $generator): ResolvedType + { + $generator->include($type->getName()); + + return new ResolvedType( + type: $type->asClass()->getShortName(), + fqcn: $type->getName(), + ); + } +} diff --git a/packages/generation/src/TypeScript/TypeResolvers/DateTimeTypeResolver.php b/packages/generation/src/TypeScript/TypeResolvers/DateTimeTypeResolver.php new file mode 100644 index 000000000..16afb2713 --- /dev/null +++ b/packages/generation/src/TypeScript/TypeResolvers/DateTimeTypeResolver.php @@ -0,0 +1,27 @@ +matches(DateTimeInterface::class) || $type->matches(NativeDateTimeInterface::class); + } + + public function resolve(TypeReflector $type, TypeScriptGenerator $generator): ResolvedType + { + return new ResolvedType('string'); + } +} diff --git a/packages/generation/src/TypeScript/TypeResolvers/EnumReferenceTypeResolver.php b/packages/generation/src/TypeScript/TypeResolvers/EnumReferenceTypeResolver.php new file mode 100644 index 000000000..f20632961 --- /dev/null +++ b/packages/generation/src/TypeScript/TypeResolvers/EnumReferenceTypeResolver.php @@ -0,0 +1,33 @@ +isEnum(); + } + + public function resolve(TypeReflector $type, TypeScriptGenerator $generator): ResolvedType + { + $generator->include($type->getName()); + + return new ResolvedType( + type: $type->asEnum()->getShortName(), + fqcn: $type->getName(), + ); + } +} diff --git a/packages/generation/src/TypeScript/TypeResolvers/MixedTypeResolver.php b/packages/generation/src/TypeScript/TypeResolvers/MixedTypeResolver.php new file mode 100644 index 000000000..ffda7b3a5 --- /dev/null +++ b/packages/generation/src/TypeScript/TypeResolvers/MixedTypeResolver.php @@ -0,0 +1,28 @@ +isBuiltIn() && in_array($type->getName(), ['string', 'int', 'float', 'bool'], strict: true); + } + + public function resolve(TypeReflector $type, TypeScriptGenerator $generator): ResolvedType + { + return new ResolvedType(match ($type->getName()) { + 'string' => 'string', + 'int', 'float' => 'number', + 'bool' => 'boolean', + }); + } +} diff --git a/packages/generation/src/TypeScript/TypeScript.php b/packages/generation/src/TypeScript/TypeScript.php new file mode 100644 index 000000000..fd7e4c49d --- /dev/null +++ b/packages/generation/src/TypeScript/TypeScript.php @@ -0,0 +1,13 @@ + + */ + public string $writer { + get; + } + + /** + * The list of source classes to generate types for. + * + * @var array + */ + public array $sources { + get; + set; + } + + /** + * The list of type resolvers for property-level type mapping. + * + * @var array> + */ + public array $resolvers { + get; + set; + } +} diff --git a/packages/generation/src/TypeScript/TypeScriptGenerator.php b/packages/generation/src/TypeScript/TypeScriptGenerator.php new file mode 100644 index 000000000..b07862a2e --- /dev/null +++ b/packages/generation/src/TypeScript/TypeScriptGenerator.php @@ -0,0 +1,21 @@ +get(TypeScriptGenerationConfig::class), + classResolver: $container->get(ClassStructureResolver::class), + enumResolver: $container->get(EnumStructureResolver::class), + ); + } +} diff --git a/packages/generation/src/TypeScript/TypeScriptOutput.php b/packages/generation/src/TypeScript/TypeScriptOutput.php new file mode 100644 index 000000000..2c8040880 --- /dev/null +++ b/packages/generation/src/TypeScript/TypeScriptOutput.php @@ -0,0 +1,64 @@ + $namespaces Type definitions grouped by namespace + * @param array $imports Additional import statements to include + */ + public function __construct( + public array $namespaces = [], + public array $imports = [], + ) {} + + /** + * Gets all type definitions across all namespaces. + * + * @return TypeDefinition[] + */ + public function getAllDefinitions(): array + { + $definitions = []; + + foreach ($this->namespaces as $namespace => $namespaceDefinitions) { + $definitions = [...$definitions, ...$namespaceDefinitions]; + } + + return $definitions; + } + + /** + * Gets all namespace names. + * + * @return string[] + */ + public function getNamespaces(): array + { + return array_keys($this->namespaces); + } + + /** + * Gets definitions for a specific namespace. + * + * @return TypeDefinition[] + */ + public function getDefinitionsForNamespace(string $namespace): array + { + return $this->namespaces[$namespace] ?? []; + } + + /** + * Checks if output has any definitions. + */ + public function isEmpty(): bool + { + return $this->namespaces === []; + } +} diff --git a/packages/generation/src/TypeScript/TypeScriptWriter.php b/packages/generation/src/TypeScript/TypeScriptWriter.php new file mode 100644 index 000000000..28a671cb3 --- /dev/null +++ b/packages/generation/src/TypeScript/TypeScriptWriter.php @@ -0,0 +1,17 @@ +getAttribute(TypeScript::class)) { + $this->discoveryItems->add($location, [$class->getName()]); + } + + if ($location->isVendor()) { + return; + } + + // TODO(innocenzi): other heuristics for implicit opt-in + + if ($class->implements(UnitEnum::class)) { + $this->discoveryItems->add($location, [$class->getName()]); + } + } + + public function apply(): void + { + foreach ($this->discoveryItems as [$className]) { + $this->config->sources[] = $className; + } + } +} diff --git a/packages/generation/src/TypeScript/TypesRepository.php b/packages/generation/src/TypeScript/TypesRepository.php new file mode 100644 index 000000000..f0819af8b --- /dev/null +++ b/packages/generation/src/TypeScript/TypesRepository.php @@ -0,0 +1,55 @@ + + */ + private array $definitions = []; + + /** + * Add a type definition to the repository. + */ + public function add(TypeDefinition|InterfaceDefinition $definition): void + { + $this->definitions[$definition->class] = $definition; + } + + /** + * Get a type definition by class name. + */ + public function get(string $class): TypeDefinition|InterfaceDefinition|null + { + return $this->definitions[$class] ?? null; + } + + /** + * Check if a definition exists for the given class. + */ + public function has(string $class): bool + { + return isset($this->definitions[$class]); + } + + /** + * Get all type definitions. + * + * @return array + */ + public function getAll(): array + { + return array_values($this->definitions); + } + + /** + * Clear all definitions. + */ + public function clear(): void + { + $this->definitions = []; + } +} diff --git a/packages/generation/src/TypeScript/Writers/DirectoryTypeScriptGenerationConfig.php b/packages/generation/src/TypeScript/Writers/DirectoryTypeScriptGenerationConfig.php new file mode 100644 index 000000000..98fccddae --- /dev/null +++ b/packages/generation/src/TypeScript/Writers/DirectoryTypeScriptGenerationConfig.php @@ -0,0 +1,29 @@ + */ + public array $sources = []; + + /** @var array> */ + public array $resolvers = []; + + /** + * @param string $directory The output directory for the generated TypeScript files. + */ + public function __construct( + public readonly string $directory, + ) {} +} diff --git a/packages/generation/src/TypeScript/Writers/DirectoryWriter.php b/packages/generation/src/TypeScript/Writers/DirectoryWriter.php new file mode 100644 index 000000000..647d0cbf8 --- /dev/null +++ b/packages/generation/src/TypeScript/Writers/DirectoryWriter.php @@ -0,0 +1,193 @@ +config->directory); + Filesystem\ensure_directory_empty($this->config->directory); + + $fileGroups = []; + + foreach ($output->namespaces as $namespace => $definitions) { + $filename = $this->namespaceToFilePath($namespace); + + $fileGroups[$filename] ??= []; + $fileGroups[$filename][$namespace] = $definitions; + } + + foreach ($fileGroups as $filename => $namespaces) { + Filesystem\write_file( + filename: $filename, + content: $this->generateFileContent($namespaces, $output), + ); + } + } + + /** + * @param array> $namespaces + */ + private function generateFileContent(array $namespaces, TypeScriptOutput $output): string + { + $lines = []; + $lines[] = '/*'; + $lines[] = '|----------------------------------------------------------------'; + $lines[] = '| This file contains TypeScript definitions generated by Tempest.'; + $lines[] = '|----------------------------------------------------------------'; + $lines[] = '*/'; + $lines[] = ''; + + $imports = $this->collectImports($namespaces, $output); + + if ($imports !== []) { + foreach ($imports as $import) { + $lines[] = $import; + } + + $lines[] = ''; + } + + foreach ($namespaces as $namespace => $definitions) { + foreach ($definitions as $definition) { + $lines[] = $this->generateDefinition($definition, $namespace); + $lines[] = ''; + } + } + + return (string) Arr\implode($lines, glue: "\n"); + } + + /** + * @param array> $namespaces + * @return array + */ + private function collectImports(array $namespaces, TypeScriptOutput $output): array + { + $imports = []; + $currentNamespaces = array_keys($namespaces); + + foreach ($namespaces as $namespace => $definitions) { + foreach ($definitions as $definition) { + if ($definition instanceof InterfaceDefinition) { + foreach ($definition->properties as $property) { + if ($property->fqcn === null) { + continue; + } + + $targetNamespace = Str\before_last($property->fqcn, '\\'); + + if (in_array($targetNamespace, $currentNamespaces, strict: true)) { + continue; + } + + $typeName = Str\after_last($property->fqcn, '\\'); + $importPath = $this->computeImportPath($namespace, $targetNamespace); + $importKey = "{$importPath}::{$typeName}"; + + $imports[$importKey] ??= "import type { {$typeName} } from '{$importPath}';"; + } + } + } + } + + return array_values($imports); + } + + private function generateDefinition(TypeDefinition|InterfaceDefinition $definition, string $currentNamespace): string + { + $typeName = Str\after_last($definition->class, '\\'); + + if ($definition instanceof TypeDefinition) { + return "export type {$typeName} = {$definition->definition};"; + } + + $lines = []; + $lines[] = "export interface {$typeName} {"; + + foreach ($definition->properties as $property) { + $lines[] = sprintf( + ' %s%s: %s;', + $property->name, + $property->isNullable ? '?' : '', + $this->resolveTypeReference($property), + ); + } + + $lines[] = '}'; + + return (string) Arr\implode($lines, glue: "\n"); + } + + private function resolveTypeReference(PropertyDefinition $property): string + { + if ($property->fqcn === null) { + return $property->definition; + } + + $targetTypeName = Str\after_last($property->fqcn, '\\'); + $arrayBrackets = Str\ends_with($property->definition, '[]') ? '[]' : ''; + + return $targetTypeName . $arrayBrackets; + } + + private function namespaceToFilePath(string $namespace): string + { + $parts = explode('\\', $namespace); + $kebabParts = Arr\map($parts, fn (string $part) => Str\to_kebab_case($part)); + $path = (string) Arr\implode($kebabParts, glue: '/'); + + return $this->config->directory . '/' . $path . '/index.ts'; + } + + private function computeImportPath(string $sourceNamespace, string $targetNamespace): string + { + $sourceParts = explode('\\', $sourceNamespace); + $targetParts = explode('\\', $targetNamespace); + + $commonLength = 0; + $minLength = min(count($sourceParts), count($targetParts)); + + for ($i = 0; $i < $minLength; $i++) { + if ($sourceParts[$i] !== $targetParts[$i]) { + break; + } + + $commonLength++; + } + + $upLevels = count($sourceParts) - $commonLength; + $targetDiff = array_slice($targetParts, $commonLength); + $targetKebab = Arr\map($targetDiff, fn (string $part) => Str\to_kebab_case($part)); + + if ($upLevels === 0 && count($targetKebab) === 0) { + return './'; + } + + $upPath = $upLevels > 0 ? str_repeat('../', $upLevels) : './'; + $downPath = count($targetKebab) > 0 ? (string) Arr\implode($targetKebab, glue: '/') : ''; + + $fullPath = rtrim($upPath . $downPath, '/'); + + return $fullPath; + } +} diff --git a/packages/generation/src/TypeScript/Writers/NamespacedFileWriter.php b/packages/generation/src/TypeScript/Writers/NamespacedFileWriter.php new file mode 100644 index 000000000..c8c5a7a98 --- /dev/null +++ b/packages/generation/src/TypeScript/Writers/NamespacedFileWriter.php @@ -0,0 +1,145 @@ +config->filename); + Filesystem\write_file($this->config->filename, $this->generateContent($output)); + } + + private function generateContent(TypeScriptOutput $output): string + { + $lines = []; + $lines[] = '/*'; + $lines[] = '|----------------------------------------------------------------'; + $lines[] = '| This file contains TypeScript definitions generated by Tempest.'; + $lines[] = '|----------------------------------------------------------------'; + $lines[] = '*/'; + $lines[] = ''; + + if ($output->imports !== []) { + foreach ($output->imports as $import) { + $lines[] = $import; + } + + $lines[] = ''; + } + + foreach ($output->namespaces as $namespace => $definitions) { + $lines[] = $this->generateNamespace($namespace, $definitions); + } + + return (string) Arr\implode($lines, glue: "\n"); + } + + /** + * @param TypeDefinition[]|InterfaceDefinition[] $definitions + */ + private function generateNamespace(string $namespace, array $definitions): string + { + $lines = []; + $lines[] = sprintf('export namespace %s {', Str\replace($namespace, '\\', '.')); + + foreach ($definitions as $definition) { + $lines[] = $this->generateDefinition($definition); + } + + $lines[] = '}'; + $lines[] = ''; + + return (string) Arr\implode($lines, glue: "\n"); + } + + private function generateDefinition(TypeDefinition|InterfaceDefinition $definition): string + { + $typeName = Str\after_last($definition->class, '\\'); + + if ($definition instanceof TypeDefinition) { + return " export type {$typeName} = {$definition->definition};"; + } + + $lines = []; + $lines[] = " export interface {$typeName} {"; + + foreach ($definition->properties as $property) { + $lines[] = sprintf( + ' %s%s: %s;', + $property->name, + $property->isNullable ? '?' : '', + $this->resolveTypeReference($property, $definition), + ); + } + + $lines[] = ' }'; + + return (string) Arr\implode($lines, glue: "\n"); + } + + private function resolveTypeReference( + PropertyDefinition $property, + InterfaceDefinition $sourceInterface, + ): string { + if ($property->fqcn === null) { + return $property->definition; + } + + $targetNamespace = Str\before_last($property->fqcn, '\\'); + $targetTypeName = Str\after_last($property->fqcn, '\\'); + $arrayBrackets = Str\ends_with($property->definition, '[]') ? '[]' : ''; + + // Same namespace, use short name + if ($sourceInterface->namespace === $targetNamespace) { + return $targetTypeName . $arrayBrackets; + } + + // Different namespace, relative path + $relativePath = $this->computeRelativeNamespacePath($sourceInterface->namespace, $targetNamespace); + + return $relativePath . $targetTypeName . $arrayBrackets; + } + + private function computeRelativeNamespacePath(string $sourceNamespace, string $targetNamespace): string + { + $commonLength = 0; + $sourceParts = explode('\\', $sourceNamespace); + $targetParts = explode('\\', $targetNamespace); + $minLength = min(count($sourceParts), count($targetParts)); + + for ($i = 0; $i < $minLength; $i++) { + if ($sourceParts[$i] !== $targetParts[$i]) { + break; + } + + $commonLength++; + } + + $targetDiff = array_slice($targetParts, $commonLength); + + if ($targetDiff === []) { + return ''; + } + + return implode('.', $targetDiff) . '.'; + } +} diff --git a/packages/generation/src/TypeScript/Writers/NamespacedTypeScriptGenerationConfig.php b/packages/generation/src/TypeScript/Writers/NamespacedTypeScriptGenerationConfig.php new file mode 100644 index 000000000..311096b23 --- /dev/null +++ b/packages/generation/src/TypeScript/Writers/NamespacedTypeScriptGenerationConfig.php @@ -0,0 +1,29 @@ + */ + public array $sources = []; + + /** @var array> */ + public array $resolvers = []; + + /** + * @param string $filename The output filename for the generated TypeScript definitions. + */ + public function __construct( + public readonly string $filename, + ) {} +} diff --git a/packages/generation/src/TypeScript/typescript.config.php b/packages/generation/src/TypeScript/typescript.config.php new file mode 100644 index 000000000..a413341f6 --- /dev/null +++ b/packages/generation/src/TypeScript/typescript.config.php @@ -0,0 +1,9 @@ +directory = sys_get_temp_dir() . '/tempest_typescript_integration_' . uniqid(); + + Filesystem\ensure_directory_exists($this->directory); + } + + #[PostCondition] + protected function cleanup(): void + { + Filesystem\delete($this->directory); + } + + #[Test] + public function generates_types(): void + { + $path = $this->directory . '/types.d.ts'; + + $container = new GenericContainer(); + $config = new NamespacedTypeScriptGenerationConfig(filename: $path); + $config->resolvers = [ + ScalarTypeResolver::class, + DateTimeTypeResolver::class, + EnumReferenceTypeResolver::class, + ClassReferenceTypeResolver::class, + MixedTypeResolver::class, + ]; + $config->sources = [User::class]; + + $generator = new GenericTypeScriptGenerator( + config: $config, + classResolver: new ClassStructureResolver($config, $container), + enumResolver: new EnumStructureResolver(), + ); + + new NamespacedFileWriter($config)->write($generator->generate()); + $content = Filesystem\read_file($path); + + $this->assertStringContainsString('export namespace Tempest.Generation.Tests.TypeScript.Fixtures {', $content); + $this->assertStringContainsString('export interface User {', $content); + $this->assertStringContainsString('full_name: string;', $content); + $this->assertStringContainsString('email: string;', $content); + $this->assertStringContainsString('created_at: string;', $content); + $this->assertStringContainsString('roles: Security.Role[];', $content); + $this->assertStringContainsString('settings: Settings;', $content); + $this->assertStringContainsString('export interface Settings {', $content); + $this->assertStringContainsString('theme: Theme;', $content); + $this->assertStringContainsString('sidebar_open: boolean;', $content); + $this->assertStringContainsString("export type Theme = 'dark' | 'light';", $content); + $this->assertStringContainsString('export namespace Tempest.Generation.Tests.TypeScript.Fixtures.Security {', $content); + $this->assertStringContainsString('export interface Role {', $content); + $this->assertStringContainsString('name: string;', $content); + $this->assertStringContainsString('permissions: Permission[];', $content); + } +} diff --git a/packages/generation/tests/TypeScript/StructureResolvers/ClassStructureResolverTest.php b/packages/generation/tests/TypeScript/StructureResolvers/ClassStructureResolverTest.php new file mode 100644 index 000000000..2259ec9f8 --- /dev/null +++ b/packages/generation/tests/TypeScript/StructureResolvers/ClassStructureResolverTest.php @@ -0,0 +1,82 @@ +resolvers = [ + ScalarTypeResolver::class, + DateTimeTypeResolver::class, + EnumReferenceTypeResolver::class, + ClassReferenceTypeResolver::class, + MixedTypeResolver::class, + ]; + + $this->resolver = new ClassStructureResolver($config, $container); + $this->generator = new GenericTypeScriptGenerator( + config: $config, + classResolver: $this->resolver, + enumResolver: new EnumStructureResolver(), + ); + } + + #[Test] + public function resolves_class_to_interface_definition(): void + { + $type = new TypeReflector(Badge::class); + + $result = $this->resolver->resolve($type, $this->generator); + + $this->assertInstanceOf(InterfaceDefinition::class, $result); + $this->assertSame(Badge::class, $result->class); + $this->assertCount(2, $result->properties); + } + + #[Test] + public function resolves_scalar_properties(): void + { + $type = new TypeReflector(Badge::class); + + $result = $this->resolver->resolve($type, $this->generator); + + $this->assertSame('name', $result->properties[0]->name); + $this->assertSame('string', $result->properties[0]->definition); + $this->assertFalse($result->properties[0]->isNullable); + + $this->assertSame('value', $result->properties[1]->name); + $this->assertSame('number', $result->properties[1]->definition); + $this->assertFalse($result->properties[1]->isNullable); + } +} + +final class Badge +{ + public string $name; + public int $value; +} diff --git a/packages/generation/tests/TypeScript/TypeScriptGenerationTest.php b/packages/generation/tests/TypeScript/TypeScriptGenerationTest.php new file mode 100644 index 000000000..0470bc581 --- /dev/null +++ b/packages/generation/tests/TypeScript/TypeScriptGenerationTest.php @@ -0,0 +1,86 @@ +directory = sys_get_temp_dir() . '/tempest_typescript_integration_' . uniqid(); + + Filesystem\ensure_directory_exists($this->directory); + } + + #[PostCondition] + protected function cleanup(): void + { + Filesystem\delete($this->directory); + } + + #[Test] + public function generates_types(): void + { + $path = $this->directory . '/types.d.ts'; + + $container = new GenericContainer(); + $config = new NamespacedTypeScriptGenerationConfig(filename: $path); + $config->resolvers = [ + ScalarTypeResolver::class, + DateTimeTypeResolver::class, + EnumReferenceTypeResolver::class, + ClassReferenceTypeResolver::class, + MixedTypeResolver::class, + ]; + $config->sources = [User::class]; + + $generator = new GenericTypeScriptGenerator( + config: $config, + classResolver: new ClassStructureResolver($config, $container), + enumResolver: new EnumStructureResolver(), + ); + + new NamespacedFileWriter($config)->write($generator->generate()); + + $content = Filesystem\read_file($path); + + $this->assertStringContainsString('export namespace Tempest.Generation.Tests.TypeScript.Fixtures {', $content); + $this->assertStringContainsString('export interface User {', $content); + $this->assertStringContainsString('full_name: string;', $content); + $this->assertStringContainsString('email: string;', $content); + $this->assertStringContainsString('age: number;', $content); + $this->assertStringContainsString('created_at: string;', $content); + $this->assertStringContainsString('roles: Security.Role[];', $content); + $this->assertStringContainsString('settings: Settings;', $content); + $this->assertStringContainsString('export interface Settings {', $content); + $this->assertStringContainsString('theme: Theme;', $content); + $this->assertStringContainsString('sidebar_open: boolean;', $content); + $this->assertStringContainsString("export type Theme = 'dark' | 'light';", $content); + $this->assertStringContainsString('export namespace Tempest.Generation.Tests.TypeScript.Fixtures.Security {', $content); + $this->assertStringContainsString('export interface Role {', $content); + $this->assertStringContainsString('name: string;', $content); + $this->assertStringContainsString('permissions: Permission[];', $content); + } +} diff --git a/packages/generation/tests/TypeScript/Writers/DirectoryWriterTest.php b/packages/generation/tests/TypeScript/Writers/DirectoryWriterTest.php new file mode 100644 index 000000000..090d8b079 --- /dev/null +++ b/packages/generation/tests/TypeScript/Writers/DirectoryWriterTest.php @@ -0,0 +1,123 @@ +directory = sys_get_temp_dir() . '/tempest_typescript_directory_' . uniqid(); + + Filesystem\ensure_directory_exists($this->directory); + } + + #[PostCondition] + protected function cleanup(): void + { + Filesystem\delete($this->directory); + } + + #[Test] + public function writes_types_in_directories(): void + { + $directory = $this->directory . '/types'; + $config = new DirectoryTypeScriptGenerationConfig(directory: $directory); + $writer = new DirectoryWriter($config); + + $userInterface = new InterfaceDefinition( + class: 'App\\Models\\User', + originalType: new TypeReflector('string'), + properties: [ + new PropertyDefinition( + name: 'id', + definition: 'number', + isNullable: true, + ), + new PropertyDefinition( + name: 'name', + definition: 'string', + isNullable: false, + ), + new PropertyDefinition( + name: 'role', + definition: 'Role', + isNullable: false, + fqcn: 'App\\Security\\Role', + ), + ], + ); + + $postType = new TypeDefinition( + class: 'App\\Models\\Post', + originalType: new TypeReflector('string'), + definition: 'string', + isNullable: false, + ); + + $roleInterface = new InterfaceDefinition( + class: 'App\\Security\\Role', + originalType: new TypeReflector('string'), + properties: [ + new PropertyDefinition( + name: 'name', + definition: 'string', + isNullable: false, + ), + ], + ); + + $themeEnum = new TypeDefinition( + class: 'App\\Models\\Theme', + originalType: new TypeReflector('string'), + definition: "'dark' | 'light'", + isNullable: false, + ); + + $output = new TypeScriptOutput( + namespaces: [ + 'App\\Models' => [$userInterface, $postType, $themeEnum], + 'App\\Security' => [$roleInterface], + ], + ); + + $writer->write($output); + + $this->assertFileExists($directory . '/app/models/index.ts'); + $this->assertFileExists($directory . '/app/security/index.ts'); + + $models = Filesystem\read_file($directory . '/app/models/index.ts'); + + $this->assertStringContainsString('This file contains TypeScript definitions generated by Tempest', $models); + $this->assertStringContainsString("import type { Role } from '../security';", $models); + $this->assertStringContainsString('export interface User {', $models); + $this->assertStringContainsString('id?: number;', $models); + $this->assertStringContainsString('name: string;', $models); + $this->assertStringContainsString('role: Role;', $models); + $this->assertStringContainsString('export type Post = string;', $models); + $this->assertStringContainsString("export type Theme = 'dark' | 'light';", $models); + + $security = Filesystem\read_file($directory . '/app/security/index.ts'); + + $this->assertStringContainsString('export interface Role {', $security); + $this->assertStringContainsString('name: string;', $security); + $this->assertStringNotContainsString('import', $security); + } +} diff --git a/packages/generation/tests/TypeScript/Writers/NamespacedFileWriterTest.php b/packages/generation/tests/TypeScript/Writers/NamespacedFileWriterTest.php new file mode 100644 index 000000000..b4f590467 --- /dev/null +++ b/packages/generation/tests/TypeScript/Writers/NamespacedFileWriterTest.php @@ -0,0 +1,121 @@ +directory = sys_get_temp_dir() . '/tempest_typescript_integration_' . uniqid(); + + Filesystem\ensure_directory_exists($this->directory); + } + + #[PostCondition] + protected function cleanup(): void + { + Filesystem\delete($this->directory); + } + + #[Test] + public function writes_types_file(): void + { + $outputPath = $this->directory . '/types.d.ts'; + $config = new NamespacedTypeScriptGenerationConfig(filename: $outputPath); + $writer = new NamespacedFileWriter($config); + + $userInterface = new InterfaceDefinition( + class: 'App\\Models\\User', + originalType: new TypeReflector('string'), + properties: [ + new PropertyDefinition( + name: 'id', + definition: 'number', + isNullable: false, + ), + new PropertyDefinition( + name: 'username', + definition: 'string', + isNullable: false, + ), + new PropertyDefinition( + name: 'email', + definition: 'string', + isNullable: true, + ), + ], + ); + + $arrayType = new TypeDefinition( + class: 'App\\Models\\Tags', + originalType: new TypeReflector('array'), + definition: 'Array', + isNullable: false, + ); + + $unionType = new TypeDefinition( + class: 'App\\Models\\Role', + originalType: new TypeReflector('string'), + definition: "'admin' | 'user' | 'guest'", + isNullable: false, + ); + + $intersectionType = new TypeDefinition( + class: 'App\\Models\\AdminUser', + originalType: new TypeReflector('object'), + definition: 'User & { permissions: Array }', + isNullable: false, + ); + + $controller = new TypeDefinition( + class: 'App\\Controllers\\HomeController', + originalType: new TypeReflector('bool'), + definition: 'boolean', + isNullable: false, + ); + + $output = new TypeScriptOutput( + namespaces: [ + 'App\\Models' => [ + $userInterface, + $arrayType, + $unionType, + $intersectionType, + ], + 'App\\Controllers' => [$controller], + ], + ); + + $writer->write($output); + $content = Filesystem\read_file($outputPath); + + $this->assertStringContainsString('export namespace App.Models {', $content); + $this->assertStringContainsString('export namespace App.Controllers {', $content); + $this->assertStringContainsString('export interface User {', $content); + $this->assertStringContainsString('id: number;', $content); + $this->assertStringContainsString('username: string;', $content); + $this->assertStringContainsString('email?: string;', $content); + $this->assertStringContainsString('export type Tags = Array;', $content); + $this->assertStringContainsString("export type Role = 'admin' | 'user' | 'guest';", $content); + $this->assertStringContainsString('export type AdminUser = User & { permissions: Array };', $content); + $this->assertStringContainsString('export type HomeController = boolean;', $content); + } +} From 299559ca265bace3f634e8e053c2c4dd9d241fc1 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Sun, 18 Jan 2026 00:43:38 +0100 Subject: [PATCH 3/9] test: use native date time to avoid requiring `tempest/datetime` --- packages/generation/tests/TypeScript/Fixtures/User.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/generation/tests/TypeScript/Fixtures/User.php b/packages/generation/tests/TypeScript/Fixtures/User.php index 322a6acf9..52ff9cd84 100644 --- a/packages/generation/tests/TypeScript/Fixtures/User.php +++ b/packages/generation/tests/TypeScript/Fixtures/User.php @@ -2,7 +2,7 @@ namespace Tempest\Generation\Tests\TypeScript\Fixtures; -use Tempest\DateTime\DateTime; +use DateTimeInterface; final class User { @@ -10,7 +10,7 @@ public function __construct( public string $full_name, public string $email, public int $age, - public DateTime $created_at, + public DateTimeInterface $created_at, /** @var \Tempest\Generation\Tests\TypeScript\Fixtures\Security\Role[] */ public array $roles, public Settings $settings, From f67e877bff9573816754e032e69bde6c035af862 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Sun, 18 Jan 2026 01:55:32 +0100 Subject: [PATCH 4/9] feat: allow support for overriding enum case type resolution --- .../EnumStructureResolver.php | 35 +++++++++++++------ .../ClassReferenceTypeResolver.php | 10 ++++-- .../TypeResolvers/EnumCaseTypeResolver.php | 34 ++++++++++++++++++ .../EnumReferenceTypeResolver.php | 6 ++-- .../TypeResolvers/ScalarTypeResolver.php | 2 +- .../TypeScript/GenerateTypesCommandTest.php | 4 ++- .../ClassStructureResolverTest.php | 2 +- .../TypeScript/TypeScriptGenerationTest.php | 4 ++- packages/reflection/src/EnumReflector.php | 10 +++++- packages/reflection/src/TypeReflector.php | 16 +++++++++ 10 files changed, 102 insertions(+), 21 deletions(-) create mode 100644 packages/generation/src/TypeScript/TypeResolvers/EnumCaseTypeResolver.php diff --git a/packages/generation/src/TypeScript/StructureResolvers/EnumStructureResolver.php b/packages/generation/src/TypeScript/StructureResolvers/EnumStructureResolver.php index ec1579d20..7293fec25 100644 --- a/packages/generation/src/TypeScript/StructureResolvers/EnumStructureResolver.php +++ b/packages/generation/src/TypeScript/StructureResolvers/EnumStructureResolver.php @@ -4,31 +4,33 @@ namespace Tempest\Generation\TypeScript\StructureResolvers; -use BackedEnum; +use ReflectionEnumBackedCase; +use ReflectionEnumUnitCase; +use RuntimeException; +use Tempest\Container\Container; use Tempest\Generation\TypeScript\StructureResolver; use Tempest\Generation\TypeScript\TypeDefinition; +use Tempest\Generation\TypeScript\TypeScriptGenerationConfig; use Tempest\Generation\TypeScript\TypeScriptGenerator; use Tempest\Reflection\TypeReflector; -use UnitEnum; /** * Resolves PHP enums into TypeScript union types. */ final class EnumStructureResolver implements StructureResolver { + public function __construct( + private readonly TypeScriptGenerationConfig $config, + private readonly Container $container, + ) {} + public function resolve(TypeReflector $type, TypeScriptGenerator $generator): TypeDefinition { $typeScriptType = implode( separator: ' | ', array: array_map( - callback: function (UnitEnum $case) { - $value = $case instanceof BackedEnum - ? $case->value - : $case->name; - - return is_string($value) ? "'{$value}'" : $value; - }, - array: $type->asEnum()->getCases(), + callback: fn (ReflectionEnumUnitCase|ReflectionEnumBackedCase $case) => $this->resolveType(new TypeReflector($case), $generator), + array: $type->asEnum()->getReflectionCases(), ), ); @@ -39,4 +41,17 @@ class: $type->getName(), isNullable: $type->isNullable(), ); } + + private function resolveType(TypeReflector $type, TypeScriptGenerator $generator): string + { + foreach ($this->config->resolvers as $resolverClass) { + $resolver = $this->container->get($resolverClass); + + if ($resolver->canResolve($type)) { + return $resolver->resolve($type, $generator)->type; + } + } + + throw new RuntimeException('No suitable type resolver found for type: ' . $type->getName()); + } } diff --git a/packages/generation/src/TypeScript/TypeResolvers/ClassReferenceTypeResolver.php b/packages/generation/src/TypeScript/TypeResolvers/ClassReferenceTypeResolver.php index d972951e5..60857e4a7 100644 --- a/packages/generation/src/TypeScript/TypeResolvers/ClassReferenceTypeResolver.php +++ b/packages/generation/src/TypeScript/TypeResolvers/ClassReferenceTypeResolver.php @@ -13,12 +13,16 @@ /** * Resolves references to PHP classes and interfaces into TypeScript type references. */ -#[Priority(Priority::NORMAL)] +#[Priority(Priority::LOW)] final class ClassReferenceTypeResolver implements TypeResolver { public function canResolve(TypeReflector $type): bool { - return ($type->isClass() || $type->isInterface()) && ! $type->isEnum(); + if ($type->isEnum() || $type->isEnumCase()) { + return false; + } + + return $type->isClass() || $type->isInterface(); } public function resolve(TypeReflector $type, TypeScriptGenerator $generator): ResolvedType @@ -26,7 +30,7 @@ public function resolve(TypeReflector $type, TypeScriptGenerator $generator): Re $generator->include($type->getName()); return new ResolvedType( - type: $type->asClass()->getShortName(), + type: $type->getShortName(), fqcn: $type->getName(), ); } diff --git a/packages/generation/src/TypeScript/TypeResolvers/EnumCaseTypeResolver.php b/packages/generation/src/TypeScript/TypeResolvers/EnumCaseTypeResolver.php new file mode 100644 index 000000000..7aca5f992 --- /dev/null +++ b/packages/generation/src/TypeScript/TypeResolvers/EnumCaseTypeResolver.php @@ -0,0 +1,34 @@ +isEnumCase(); + } + + public function resolve(TypeReflector $type, TypeScriptGenerator $generator): ResolvedType + { + $case = $type->asEnumCase()->getValue(); + $value = $case instanceof BackedEnum + ? $case->value + : $case->name; + + return new ResolvedType(is_string($value) ? "'{$value}'" : $value); + } +} diff --git a/packages/generation/src/TypeScript/TypeResolvers/EnumReferenceTypeResolver.php b/packages/generation/src/TypeScript/TypeResolvers/EnumReferenceTypeResolver.php index f20632961..b5ef92dfd 100644 --- a/packages/generation/src/TypeScript/TypeResolvers/EnumReferenceTypeResolver.php +++ b/packages/generation/src/TypeScript/TypeResolvers/EnumReferenceTypeResolver.php @@ -13,12 +13,12 @@ /** * Resolves references to PHP enums into TypeScript type references. */ -#[Priority(Priority::HIGH)] +#[Priority(Priority::LOW)] final class EnumReferenceTypeResolver implements TypeResolver { public function canResolve(TypeReflector $type): bool { - return $type->isEnum(); + return $type->isEnum() && ! $type->isEnumCase(); } public function resolve(TypeReflector $type, TypeScriptGenerator $generator): ResolvedType @@ -26,7 +26,7 @@ public function resolve(TypeReflector $type, TypeScriptGenerator $generator): Re $generator->include($type->getName()); return new ResolvedType( - type: $type->asEnum()->getShortName(), + type: $type->getShortName(), fqcn: $type->getName(), ); } diff --git a/packages/generation/src/TypeScript/TypeResolvers/ScalarTypeResolver.php b/packages/generation/src/TypeScript/TypeResolvers/ScalarTypeResolver.php index 794b4999e..73765ef03 100644 --- a/packages/generation/src/TypeScript/TypeResolvers/ScalarTypeResolver.php +++ b/packages/generation/src/TypeScript/TypeResolvers/ScalarTypeResolver.php @@ -10,7 +10,7 @@ use Tempest\Generation\TypeScript\TypeScriptGenerator; use Tempest\Reflection\TypeReflector; -#[Priority(Priority::HIGH)] +#[Priority(Priority::LOW)] final class ScalarTypeResolver implements TypeResolver { public function canResolve(TypeReflector $type): bool diff --git a/packages/generation/tests/TypeScript/GenerateTypesCommandTest.php b/packages/generation/tests/TypeScript/GenerateTypesCommandTest.php index 90d5f0fc6..7815d1617 100644 --- a/packages/generation/tests/TypeScript/GenerateTypesCommandTest.php +++ b/packages/generation/tests/TypeScript/GenerateTypesCommandTest.php @@ -15,6 +15,7 @@ use Tempest\Generation\TypeScript\StructureResolvers\EnumStructureResolver; use Tempest\Generation\TypeScript\TypeResolvers\ClassReferenceTypeResolver; use Tempest\Generation\TypeScript\TypeResolvers\DateTimeTypeResolver; +use Tempest\Generation\TypeScript\TypeResolvers\EnumCaseTypeResolver; use Tempest\Generation\TypeScript\TypeResolvers\EnumReferenceTypeResolver; use Tempest\Generation\TypeScript\TypeResolvers\MixedTypeResolver; use Tempest\Generation\TypeScript\TypeResolvers\ScalarTypeResolver; @@ -48,6 +49,7 @@ public function generates_types(): void $container = new GenericContainer(); $config = new NamespacedTypeScriptGenerationConfig(filename: $path); $config->resolvers = [ + EnumCaseTypeResolver::class, ScalarTypeResolver::class, DateTimeTypeResolver::class, EnumReferenceTypeResolver::class, @@ -59,7 +61,7 @@ public function generates_types(): void $generator = new GenericTypeScriptGenerator( config: $config, classResolver: new ClassStructureResolver($config, $container), - enumResolver: new EnumStructureResolver(), + enumResolver: new EnumStructureResolver($config, $container), ); new NamespacedFileWriter($config)->write($generator->generate()); diff --git a/packages/generation/tests/TypeScript/StructureResolvers/ClassStructureResolverTest.php b/packages/generation/tests/TypeScript/StructureResolvers/ClassStructureResolverTest.php index 2259ec9f8..578ca75fd 100644 --- a/packages/generation/tests/TypeScript/StructureResolvers/ClassStructureResolverTest.php +++ b/packages/generation/tests/TypeScript/StructureResolvers/ClassStructureResolverTest.php @@ -42,7 +42,7 @@ protected function configure(): void $this->generator = new GenericTypeScriptGenerator( config: $config, classResolver: $this->resolver, - enumResolver: new EnumStructureResolver(), + enumResolver: new EnumStructureResolver($config, $container), ); } diff --git a/packages/generation/tests/TypeScript/TypeScriptGenerationTest.php b/packages/generation/tests/TypeScript/TypeScriptGenerationTest.php index 0470bc581..764d0331f 100644 --- a/packages/generation/tests/TypeScript/TypeScriptGenerationTest.php +++ b/packages/generation/tests/TypeScript/TypeScriptGenerationTest.php @@ -15,6 +15,7 @@ use Tempest\Generation\TypeScript\StructureResolvers\EnumStructureResolver; use Tempest\Generation\TypeScript\TypeResolvers\ClassReferenceTypeResolver; use Tempest\Generation\TypeScript\TypeResolvers\DateTimeTypeResolver; +use Tempest\Generation\TypeScript\TypeResolvers\EnumCaseTypeResolver; use Tempest\Generation\TypeScript\TypeResolvers\EnumReferenceTypeResolver; use Tempest\Generation\TypeScript\TypeResolvers\MixedTypeResolver; use Tempest\Generation\TypeScript\TypeResolvers\ScalarTypeResolver; @@ -50,6 +51,7 @@ public function generates_types(): void $config->resolvers = [ ScalarTypeResolver::class, DateTimeTypeResolver::class, + EnumCaseTypeResolver::class, EnumReferenceTypeResolver::class, ClassReferenceTypeResolver::class, MixedTypeResolver::class, @@ -59,7 +61,7 @@ public function generates_types(): void $generator = new GenericTypeScriptGenerator( config: $config, classResolver: new ClassStructureResolver($config, $container), - enumResolver: new EnumStructureResolver(), + enumResolver: new EnumStructureResolver($config, $container), ); new NamespacedFileWriter($config)->write($generator->generate()); diff --git a/packages/reflection/src/EnumReflector.php b/packages/reflection/src/EnumReflector.php index bb59cf20a..c5fdd3539 100644 --- a/packages/reflection/src/EnumReflector.php +++ b/packages/reflection/src/EnumReflector.php @@ -89,11 +89,19 @@ public function getCases(): array key: 'cases', closure: fn () => array_map( callback: fn (ReflectionEnumUnitCase $case) => $case->getValue(), - array: $this->reflectionEnum->getCases(), + array: $this->getReflectionCases(), ), ); } + /** + * @return \ReflectionEnumUnitCase[]|\ReflectionEnumBackedCase[] + */ + public function getReflectionCases(): array + { + return $this->reflectionEnum->getCases(); + } + public function hasCase(string $name): bool { return $this->reflectionEnum->hasCase($name); diff --git a/packages/reflection/src/TypeReflector.php b/packages/reflection/src/TypeReflector.php index e3813c30f..b951adc2e 100644 --- a/packages/reflection/src/TypeReflector.php +++ b/packages/reflection/src/TypeReflector.php @@ -9,6 +9,8 @@ use Generator; use Iterator; use ReflectionClass as PHPReflectionClass; +use ReflectionClassConstant as PHPReflectionClassConstant; +use ReflectionEnumUnitCase as PHPReflectionEnumUnitCase; use ReflectionIntersectionType as PHPReflectionIntersectionType; use ReflectionNamedType as PHPReflectionNamedType; use ReflectionParameter as PHPReflectionParameter; @@ -162,6 +164,16 @@ public function isEnum(): bool return $this->isUnitEnum() || $this->isBackedEnum(); } + public function isEnumCase(): bool + { + return $this->reflector instanceof PHPReflectionEnumUnitCase; + } + + public function asEnumCase(): PHPReflectionEnumUnitCase + { + return $this->reflector; + } + public function isUnitEnum(): bool { return $this->matches(UnitEnum::class); @@ -249,6 +261,10 @@ private function resolveDefinition(PHPReflector|PHPReflectionType|string $reflec return $reflector->getName(); } + if ($reflector instanceof PHPReflectionClassConstant) { + return $reflector->getDeclaringClass()->getName(); + } + if ($reflector instanceof PHPReflectionUnionType) { return implode('|', array_map( fn (PHPReflectionType $reflectionType) => $this->resolveDefinition($reflectionType), From 5b6047ab3dbdf4bd54b1b0d1872a93d8fa0fe50c Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Sun, 18 Jan 2026 02:01:31 +0100 Subject: [PATCH 5/9] refactor: clean up priority sorting --- .../src/TypeScript/TypeResolverDiscovery.php | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/packages/generation/src/TypeScript/TypeResolverDiscovery.php b/packages/generation/src/TypeScript/TypeResolverDiscovery.php index a714125c2..1761028c4 100644 --- a/packages/generation/src/TypeScript/TypeResolverDiscovery.php +++ b/packages/generation/src/TypeScript/TypeResolverDiscovery.php @@ -10,6 +10,8 @@ use Tempest\Discovery\IsDiscovery; use Tempest\Reflection\ClassReflector; +use function Tempest\Support\arr; + final class TypeResolverDiscovery implements Discovery { use IsDiscovery; @@ -30,16 +32,9 @@ public function discover(DiscoveryLocation $location, ClassReflector $class): vo public function apply(): void { - // Collect all resolvers with their priorities - $resolvers = []; - foreach ($this->discoveryItems as [$className, $priority]) { - $resolvers[] = ['class' => $className, 'priority' => $priority]; - } - - // Sort by priority (lower values first - framework uses ascending priority) - usort($resolvers, fn ($a, $b) => $a['priority'] <=> $b['priority']); - - // Extract just the class names - $this->config->resolvers = array_column($resolvers, 'class'); + $this->config->resolvers = arr([...$this->discoveryItems]) + ->sortByCallback(fn (array $a, array $b) => $a[1] <=> $b[1]) + ->map(fn (array $item) => $item[0]) + ->toArray(); } } From 83e9d7efdba373ca1bbe7c9e1a2bef359acaf00a Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Sun, 18 Jan 2026 20:45:27 +0100 Subject: [PATCH 6/9] refactor: minor clean up --- .../generation/src/TypeScript/ResolvedType.php | 2 +- .../generation/src/TypeScript/TypeDefinition.php | 1 - .../TypeResolvers/EnumCaseTypeResolver.php | 3 --- .../src/TypeScript/TypeScriptWriter.php | 5 ++--- .../src/TypeScript/TypeSourceDiscovery.php | 5 +++-- .../src/TypeScript/TypesRepository.php | 16 ++++------------ 6 files changed, 10 insertions(+), 22 deletions(-) diff --git a/packages/generation/src/TypeScript/ResolvedType.php b/packages/generation/src/TypeScript/ResolvedType.php index e93d1ce76..93151bd91 100644 --- a/packages/generation/src/TypeScript/ResolvedType.php +++ b/packages/generation/src/TypeScript/ResolvedType.php @@ -5,7 +5,7 @@ namespace Tempest\Generation\TypeScript; /** - * Result of resolving a PHP type to TypeScript. + * Represents a PHP type resolved to a TypeScript one as a string. */ final readonly class ResolvedType { diff --git a/packages/generation/src/TypeScript/TypeDefinition.php b/packages/generation/src/TypeScript/TypeDefinition.php index 8f2eed151..5c08b0443 100644 --- a/packages/generation/src/TypeScript/TypeDefinition.php +++ b/packages/generation/src/TypeScript/TypeDefinition.php @@ -9,7 +9,6 @@ /** * Represents a TypeScript type alias definition. - * Example: export type Status = 'active' | 'inactive'; */ final class TypeDefinition { diff --git a/packages/generation/src/TypeScript/TypeResolvers/EnumCaseTypeResolver.php b/packages/generation/src/TypeScript/TypeResolvers/EnumCaseTypeResolver.php index 7aca5f992..c6ed14ccb 100644 --- a/packages/generation/src/TypeScript/TypeResolvers/EnumCaseTypeResolver.php +++ b/packages/generation/src/TypeScript/TypeResolvers/EnumCaseTypeResolver.php @@ -11,9 +11,6 @@ use Tempest\Generation\TypeScript\TypeScriptGenerator; use Tempest\Reflection\TypeReflector; -/** - * Resolves enum cases to TypeScript types. - */ #[Priority(Priority::LOW)] final class EnumCaseTypeResolver implements TypeResolver { diff --git a/packages/generation/src/TypeScript/TypeScriptWriter.php b/packages/generation/src/TypeScript/TypeScriptWriter.php index 28a671cb3..1fa8f1593 100644 --- a/packages/generation/src/TypeScript/TypeScriptWriter.php +++ b/packages/generation/src/TypeScript/TypeScriptWriter.php @@ -5,13 +5,12 @@ namespace Tempest\Generation\TypeScript; /** - * Interface for writing TypeScript type definitions to different output formats. - * Implementations receive their configuration (destination, options, etc.) via constructor injection. + * Responsible for writing TypeScript type definitions to different output formats. */ interface TypeScriptWriter { /** - * Write the TypeScript output. + * Writes the TypeScript output. */ public function write(TypeScriptOutput $output): void; } diff --git a/packages/generation/src/TypeScript/TypeSourceDiscovery.php b/packages/generation/src/TypeScript/TypeSourceDiscovery.php index 40272afdb..569646848 100644 --- a/packages/generation/src/TypeScript/TypeSourceDiscovery.php +++ b/packages/generation/src/TypeScript/TypeSourceDiscovery.php @@ -22,12 +22,13 @@ public function discover(DiscoveryLocation $location, ClassReflector $class): vo $this->discoveryItems->add($location, [$class->getName()]); } + // TODO(innocenzi): other heuristics for implicit opt-in + // eg. automatically convert DTOs, excluding vendor ones + if ($location->isVendor()) { return; } - // TODO(innocenzi): other heuristics for implicit opt-in - if ($class->implements(UnitEnum::class)) { $this->discoveryItems->add($location, [$class->getName()]); } diff --git a/packages/generation/src/TypeScript/TypesRepository.php b/packages/generation/src/TypeScript/TypesRepository.php index f0819af8b..2f83ba309 100644 --- a/packages/generation/src/TypeScript/TypesRepository.php +++ b/packages/generation/src/TypeScript/TypesRepository.php @@ -12,7 +12,7 @@ final class TypesRepository private array $definitions = []; /** - * Add a type definition to the repository. + * Adds a type definition to the repository. */ public function add(TypeDefinition|InterfaceDefinition $definition): void { @@ -20,7 +20,7 @@ public function add(TypeDefinition|InterfaceDefinition $definition): void } /** - * Get a type definition by class name. + * Gets a type definition by class name. */ public function get(string $class): TypeDefinition|InterfaceDefinition|null { @@ -28,7 +28,7 @@ public function get(string $class): TypeDefinition|InterfaceDefinition|null } /** - * Check if a definition exists for the given class. + * Checks if a definition exists for the given class. */ public function has(string $class): bool { @@ -36,7 +36,7 @@ public function has(string $class): bool } /** - * Get all type definitions. + * Gets all type definitions. * * @return array */ @@ -44,12 +44,4 @@ public function getAll(): array { return array_values($this->definitions); } - - /** - * Clear all definitions. - */ - public function clear(): void - { - $this->definitions = []; - } } From 10219bb636016308c51fc94e37434f30f89a0149 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Mon, 19 Jan 2026 12:53:45 +0100 Subject: [PATCH 7/9] chore: add typescript codegen to codeowners --- .github/CODEOWNERS | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7c3856518..3aa73eee7 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -19,6 +19,7 @@ /packages/discovery/ @brendt @aidan-casey /packages/event-bus/ @brendt @aidan-casey /packages/generation/ @brendt +/packages/generation/src/TypeScript @innocenzi /packages/http/ @brendt @aidan-casey /packages/http-client/ @aidan-casey /packages/icon/ @innocenzi From bc2ddfc408147504c6a1161268fddc4fc1afaa30 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Wed, 21 Jan 2026 14:31:47 +0100 Subject: [PATCH 8/9] docs: document typescript generation --- docs/2-features/18-typescript.md | 107 +++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 docs/2-features/18-typescript.md diff --git a/docs/2-features/18-typescript.md b/docs/2-features/18-typescript.md new file mode 100644 index 000000000..00965e198 --- /dev/null +++ b/docs/2-features/18-typescript.md @@ -0,0 +1,107 @@ +--- +title: TypeScript +description: "Tempest provides the ability to generate TypeScript interfaces from PHP classes to ease integration with TypeScript-based front-ends." +keywords: ["Experimental", "Generation"] +experimental: true +--- + +## Overview + +When building applications with TypeScript-based front-ends like [Inertia](https://inertiajs.com), keeping your client-side types synchronized with your PHP backend can be tedious and error-prone. + +Tempest solves this by automatically generating TypeScript definitions from your PHP value objects, data transfer objects, and enums. + +You can choose to output a single `.d.ts` declaration file or a directory tree of individual `.ts` modules, depending on your project's needs. + +## Generating types + +Mark any PHP class with the {b`#[Tempest\Generation\TypeScript\AsType]`} attribute to instruct Tempest that a matching TypeScript interface must be generated based on its public properties. + +By default, all application enums are also included automatically without needing an attribute. Generate your TypeScript definitions by running `generate:typescript-types`: + +```sh ">_ generate:typescript-types" +✓ // Generated 14 type definitions across 2 namespaces. +``` + +This command scans your marked classes, generates the corresponding TypeScript definitions, and writes them to your configured output location. + +## Customizing type resolution + +Tempest provides several built-in type resolvers for common types: strings, numbers, dates, enums and class references. + +You can add your own resolver by providing implementations of {b`Tempest\Generation\TypeScript\TypeResolvers\TypeResolver`}. This interface requires a `canResolve()` method to determine if the resolver can handle a given type, and a `resolve()` method to perform the actual resolution. + +The following is the actual implementation of the built-in resolver that handles scalar types: + +```php ScalarTypeResolver.php +#[Priority(Priority::LOW)] +final class ScalarTypeResolver implements TypeResolver +{ + public function canResolve(TypeReflector $type): bool + { + return $type->isBuiltIn() + && in_array($type->getName(), ['string', 'int', 'float', 'bool'], strict: true); + } + + public function resolve(TypeReflector $type, TypeScriptGenerator $generator): ResolvedType + { + return new ResolvedType(match ($type->getName()) { + 'string' => 'string', + 'int', 'float' => 'number', + 'bool' => 'boolean', + }); + } +} +``` + +:::info +Type resolvers are automatically [discovered](../1-essentials/05-discovery.md) and do not need to be registered manually. +::: + +## Configuring output location + +By default, Tempest generates a `types.d.ts` definition file at the root of the project, in which the generated types are organized by namespace. + +This may be configured by creating a `typescript.config.php` [configuration file](../1-essentials/06-configuration.md#configuration-files) and returning one of the available configuration objects. + +### Single file output + +To keep all of the TypeScript definitions in a single `.d.ts` declaration file, which is the default, return a {b`Tempest\Generation\TypeScript\Writers\NamespacedTypeScriptGenerationConfig`} object and specify the desired output filename. + +```php +use Tempest\Generation\TypeScript\Writers\NamespacedTypeScriptGenerationConfig; + +return new NamespacedTypeScriptGenerationConfig( + filename: 'types.d.ts', +); +``` + +The declaration file should be automatically picked up by TypeScript—if not, ensure that it's included in the `include` property of your `tsconfig.json`: + +```json +{ + "include": ["types.d.ts"] +} +``` + +You may then reference the generated types globally by using their namespaces: + +```ts +defineProps<{ + entry: Module.Changelog.ChangelogEntry +}>() +``` + +### Directory structure output + +If you prefer to mirror your PHP namespace structure in separate files, you may return a {b`Tempest\Generation\TypeScript\Writers\DirectoryTypeScriptGenerationConfig`} configuration object: + +```php +use Tempest\Generation\TypeScript\Writers\DirectoryTypeScriptGenerationConfig; + +return new DirectoryTypeScriptGenerationConfig( + directory: 'src/Web/types', +); +``` + +This creates a directory tree of individual `.ts` files, making it easier to navigate your types. Each namespace gets its own file, and imports between files are handled automatically. From f97918fae9af098bf013b04c0bb800b1c8bdd1b2 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Wed, 21 Jan 2026 14:32:15 +0100 Subject: [PATCH 9/9] refactor: rename `TypeScript` to `AsType` --- .../generation/src/TypeScript/{TypeScript.php => AsType.php} | 2 +- packages/generation/src/TypeScript/TypeSourceDiscovery.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename packages/generation/src/TypeScript/{TypeScript.php => AsType.php} (88%) diff --git a/packages/generation/src/TypeScript/TypeScript.php b/packages/generation/src/TypeScript/AsType.php similarity index 88% rename from packages/generation/src/TypeScript/TypeScript.php rename to packages/generation/src/TypeScript/AsType.php index fd7e4c49d..dd4fc72e4 100644 --- a/packages/generation/src/TypeScript/TypeScript.php +++ b/packages/generation/src/TypeScript/AsType.php @@ -8,6 +8,6 @@ * Marks this class as a source for TypeScript type generation. */ #[Attribute(Attribute::TARGET_CLASS)] -final class TypeScript +final class AsType { } diff --git a/packages/generation/src/TypeScript/TypeSourceDiscovery.php b/packages/generation/src/TypeScript/TypeSourceDiscovery.php index 569646848..3d69a7fc0 100644 --- a/packages/generation/src/TypeScript/TypeSourceDiscovery.php +++ b/packages/generation/src/TypeScript/TypeSourceDiscovery.php @@ -18,7 +18,7 @@ public function __construct( public function discover(DiscoveryLocation $location, ClassReflector $class): void { - if ($class->getAttribute(TypeScript::class)) { + if ($class->getAttribute(AsType::class)) { $this->discoveryItems->add($location, [$class->getName()]); }