Repository: twigphp/Twig (php)

Issue:
`types` tag "argument" is not a mapping
While working a bit with types, I realized that I don't like the way we declare types via the `types` tag:

 * In the docs, we say that `types` uses a mapping, which is not true:
   * Mappings don't support keys like `foo?`
   * Mappings support more than strings as values
   * Mappings support nesting
 * With that in mind, `{}` and `,` look more like noise to me as it makes the syntax more verbose for no good reasons
 * Less important, one might think that they could declare a mapping and use it as types: `{% type some_mapping_declared_with_set %}`

So, I'd like to get rid of this concept of a pseudo mapping.

For all tags, we're trying to have the "most possible readable syntax". This is highly subjective, but what about something more similar to what we're doing for other tags like:

```
{% types
    is_correct as 'boolean'
    score? as 'number'
%}
```

I'm not 100% confident that my proposal is the best one, but at least, it's a baseline for discussion.

/cc @drjayvee @ruudk 


Pull request description:
Make {} optional for the types tag
Closes #4532

Patch:
diff --git a/CHANGELOG b/CHANGELOG
index 71eb1081..f1d757d2 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,5 +1,6 @@
 # 3.19.0 (2025-XX-XX)
 
+ * Make `{}` optional for the `types` tag
  * Add `LastModifiedExtensionInterface` and implementation in `AbstractExtension` to track modification of runtime classes
 
 # 3.18.0 (2024-12-29)
diff --git a/doc/tags/types.rst b/doc/tags/types.rst
index a4e4a6c1..c3a17539 100644
--- a/doc/tags/types.rst
+++ b/doc/tags/types.rst
@@ -6,17 +6,23 @@
     The ``types`` tag was added in Twig 3.13. This tag is **experimental** and
     can change based on usage and feedback.
 
-The ``types`` tag declares the types of template variables.
+Use the ``types`` tag to declare the type of a variable:
 
-.. note::
+.. code-block:: twig
 
-    The types declared in a template are local to that template and must not be
-    propagated to included templates. This is because a template can be
-    included from multiple different places, each potentially having different
-    variable types.
+    {% types is_correct: 'boolean' %}
+    {% types score: 'number' %}
 
-Here is how to declare that ``is_correct`` is a boolean, while ``score`` is a
-number (see note below):
+Or multiple variables:
+
+.. code-block:: twig
+
+    {% types
+        is_correct: 'boolean',
+        score: 'number',
+    %}
+
+You can also enclose types with ``{}``:
 
 .. code-block:: twig
 
@@ -25,7 +31,7 @@ number (see note below):
         score: 'number',
     } %}
 
-You can declare variables as optional by adding the ``?`` suffix:
+Declare optional variables by adding a ``?`` suffix:
 
 .. code-block:: twig
 
@@ -43,6 +49,13 @@ validate variables or their types, this tag enables extensions to do this.
 Additionally, :ref:`Twig extensions <creating_extensions>` can analyze these
 tags to perform compile-time and runtime analysis of templates.
 
+.. note::
+
+    The types declared in a template are local to that template and must not be
+    propagated to included templates. This is because a template can be
+    included from multiple different places, each potentially having different
+    variable types.
+
 .. note::
 
     The syntax for and contents of type strings are intentionally left out of
diff --git a/src/TokenParser/TypesTokenParser.php b/src/TokenParser/TypesTokenParser.php
index 02aef81c..a7da0f5e 100644
--- a/src/TokenParser/TypesTokenParser.php
+++ b/src/TokenParser/TypesTokenParser.php
@@ -31,9 +31,7 @@ final class TypesTokenParser extends AbstractTokenParser
     public function parse(Token $token): Node
     {
         $stream = $this->parser->getStream();
-
         $types = $this->parseSimpleMappingExpression($stream);
-
         $stream->expect(Token::BLOCK_END_TYPE);
 
         return new TypesNode($types, $token->getLine());
@@ -46,17 +44,15 @@ final class TypesTokenParser extends AbstractTokenParser
      */
     private function parseSimpleMappingExpression(TokenStream $stream): array
     {
-        $stream->expect(Token::PUNCTUATION_TYPE, '{', 'A mapping element was expected');
-
+        $enclosed = null !== $stream->nextIf(Token::PUNCTUATION_TYPE, '{');
         $types = [];
-
         $first = true;
-        while (!$stream->test(Token::PUNCTUATION_TYPE, '}')) {
+        while (!($stream->test(Token::PUNCTUATION_TYPE, '}') || $stream->test(Token::BLOCK_END_TYPE))) {
             if (!$first) {
                 $stream->expect(Token::PUNCTUATION_TYPE, ',', 'A type string must be followed by a comma');
 
                 // trailing ,?
-                if ($stream->test(Token::PUNCTUATION_TYPE, '}')) {
+                if ($stream->test(Token::PUNCTUATION_TYPE, '}') || $stream->test(Token::BLOCK_END_TYPE)) {
                     break;
                 }
             }
@@ -78,7 +74,10 @@ final class TypesTokenParser extends AbstractTokenParser
                 'optional' => $isOptional,
             ];
         }
-        $stream->expect(Token::PUNCTUATION_TYPE, '}', 'An opened mapping is not properly closed');
+
+        if ($enclosed) {
+            $stream->expect(Token::PUNCTUATION_TYPE, '}', 'An opened mapping is not properly closed');
+        }
 
         return $types;
     }


Test patch:
diff --git a/tests/TokenParser/TypesTokenParserTest.php b/tests/TokenParser/TypesTokenParserTest.php
index 400820fc..0acbb4dd 100644
--- a/tests/TokenParser/TypesTokenParserTest.php
+++ b/tests/TokenParser/TypesTokenParserTest.php
@@ -64,6 +64,15 @@ class TypesTokenParserTest extends TestCase
                     'baz' => ['type' => 'baz', 'optional' => false],
                 ],
             ],
+
+            // without {} enclosing
+            [
+                '{% types foo: "foo", bar: "bar" %}',
+                [
+                    'foo' => ['type' => 'foo', 'optional' => false],
+                    'bar' => ['type' => 'bar', 'optional' => false],
+                ],
+            ],
         ];
     }
 }