diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
index 8459a8e..18a35e9 100644
--- a/.github/workflows/checks.yml
+++ b/.github/workflows/checks.yml
@@ -174,7 +174,7 @@ jobs:
             -jmax
 
       - name: Save Infection result
-        uses: actions/upload-artifact@v3
+        uses: actions/upload-artifact@v4
         if: always()
         with:
           name: infection-log-${{ matrix.php }}-${{ matrix.deps }}.txt
diff --git a/composer.json b/composer.json
index 97ff953..cb4d6d6 100644
--- a/composer.json
+++ b/composer.json
@@ -5,19 +5,20 @@
     "license": "MIT",
     "require": {
         "php": ">=8.1",
-        "ext-json": "*"
+        "ext-json": "*",
+        "ext-mbstring": "*"
     },
     "require-dev": {
         "eventjet/coding-standard": "^3.15",
         "infection/infection": "^0.26.20",
         "maglnet/composer-require-checker": "^4.6",
         "phpstan/extension-installer": "^1.3",
-        "phpstan/phpstan": "^1.10",
-        "phpstan/phpstan-phpunit": "^1.3",
-        "phpstan/phpstan-strict-rules": "^1.5",
+        "phpstan/phpstan": "^2.1",
+        "phpstan/phpstan-phpunit": "^2.0",
+        "phpstan/phpstan-strict-rules": "^2.0",
         "phpunit/phpunit": "^10.1",
-        "psalm/plugin-phpunit": "^0.18.4",
-        "vimeo/psalm": "^5.23"
+        "psalm/plugin-phpunit": "^0.19.0",
+        "vimeo/psalm": "^5.26"
     },
     "config": {
         "allow-plugins": {
diff --git a/src/Json.php b/src/Json.php
index 56e3be0..cc04039 100644
--- a/src/Json.php
+++ b/src/Json.php
@@ -5,15 +5,17 @@
 namespace Eventjet\Json;
 
 use BackedEnum;
+use Eventjet\Json\Parser\Parser;
+use Eventjet\Json\Parser\SyntaxError;
 use ReflectionClass;
 use ReflectionNamedType;
 use ReflectionObject;
 use ReflectionParameter;
 use ReflectionProperty;
 use ReflectionUnionType;
+use stdClass;
 
 use function array_is_list;
-use function array_key_exists;
 use function array_map;
 use function assert;
 use function class_exists;
@@ -21,6 +23,7 @@
 use function error_get_last;
 use function explode;
 use function file;
+use function get_debug_type;
 use function get_object_vars;
 use function gettype;
 use function implode;
@@ -31,7 +34,6 @@
 use function is_object;
 use function is_string;
 use function is_subclass_of;
-use function json_decode;
 use function json_encode;
 use function preg_match;
 use function property_exists;
@@ -123,12 +125,16 @@ private static function getJsonKeyForProperty(ReflectionProperty $property): str
      */
     private static function decodeClass(string $json, object|string $value): object
     {
-        $data = json_decode($json, true);
+        try {
+            $data = Parser::parse($json);
+        } catch (SyntaxError $syntaxError) {
+            throw JsonError::decodeFailed(sprintf('JSON decoding failed: %s', $syntaxError->getMessage()), $syntaxError);
+        }
         if ($data === null) {
             throw JsonError::decodeFailed(error_get_last()['message'] ?? null);
         }
-        if (!is_array($data)) {
-            throw JsonError::decodeFailed(sprintf("Expected JSON object, got %s", gettype($data)));
+        if (!$data instanceof stdClass) {
+            throw JsonError::decodeFailed(sprintf('Expected JSON object, got %s', get_debug_type($data)));
         }
         /** @psalm-suppress DocblockTypeContradiction */
         if (!is_string($value)) {
@@ -145,12 +151,14 @@ private static function decodeClass(string $json, object|string $value): object
         return $object;
     }
 
-    /**
-     * @param array<array-key, mixed> $data
-     */
-    private static function populateObject(object $object, array $data): void
+    private static function populateObject(object $object, stdClass $data): void
     {
-        /** @var mixed $value */
+        /**
+         * @var array-key $jsonKey
+         * @var mixed $value
+         * @psalm-suppress RawObjectIteration
+         * @phpstan-ignore-next-line foreach.nonIterable stdClass _is_ iterable
+         */
         foreach ($data as $jsonKey => $value) {
             if (is_int($jsonKey)) {
                 throw JsonError::decodeFailed(sprintf('Expected JSON object, got array at key "%s"', $jsonKey));
@@ -179,12 +187,17 @@ private static function propertyForObjectKeyExists(object $object, string $jsonK
     private static function populateProperty(object $object, string $jsonKey, mixed $value): void
     {
         $property = self::getPropertyNameForJsonKey($object, $jsonKey);
+        if ($value instanceof stdClass) {
+            $newValue = self::getPropertyObject($object, $jsonKey);
+            self::populateObject($newValue, $value);
+            $value = $newValue;
+        }
         if (is_array($value)) {
             $itemType = self::getArrayPropertyItemType($object, $property);
             if ($itemType !== null && class_exists($itemType)) {
                 /** @var mixed $item */
                 foreach ($value as &$item) {
-                    if (!is_array($item)) {
+                    if (!$item instanceof stdClass) {
                         throw JsonError::decodeFailed(
                             sprintf(
                                 'Expected JSON objects for items in property "%s", got %s',
@@ -199,11 +212,6 @@ private static function populateProperty(object $object, string $jsonKey, mixed
                     $item = $newItem;
                 }
             }
-            if (!array_is_list($value)) {
-                $newValue = self::getPropertyObject($object, $jsonKey);
-                self::populateObject($newValue, $value);
-                $value = $newValue;
-            }
         }
         $object->$property = $value; // @phpstan-ignore-line
     }
@@ -260,9 +268,8 @@ private static function getPropertyObject(object $value, string $key): object
 
     /**
      * @param class-string $class
-     * @param array<array-key, mixed> $data
      */
-    private static function instantiateClass(string $class, array $data): object
+    private static function instantiateClass(string $class, stdClass $data): object
     {
         $classReflection = new ReflectionClass($class);
         $constructor = $classReflection->getConstructor();
@@ -271,7 +278,7 @@ private static function instantiateClass(string $class, array $data): object
             $parameters = $constructor->getParameters();
             foreach ($parameters as $parameter) {
                 $name = $parameter->getName();
-                if (!array_key_exists($name, $data)) {
+                if (!property_exists($data, $name)) {
                     if ($parameter->isOptional()) {
                         /** @psalm-suppress MixedAssignment */
                         $arguments[] = $parameter->getDefaultValue();
@@ -280,14 +287,12 @@ private static function instantiateClass(string $class, array $data): object
                     throw JsonError::decodeFailed(sprintf('Missing required constructor argument "%s"', $name));
                 }
                 /** @psalm-suppress MixedAssignment */
-                $arguments[] = self::createConstructorArgument($parameter, $data[$name]);
-                unset($data[$name]);
+                $arguments[] = self::createConstructorArgument($parameter, $data->$name);
+                unset($data->$name);
             }
         }
         $instance = $classReflection->newInstanceArgs($arguments);
-        if ($data !== []) {
-            self::populateObject($instance, $data);
-        }
+        self::populateObject($instance, $data);
         return $instance;
     }
 
@@ -345,7 +350,7 @@ private static function createConstructorArgumentForNamedType(ReflectionParamete
                 ),
             );
         }
-        if (!is_array($value)) {
+        if (!$value instanceof stdClass) {
             throw JsonError::decodeFailed(
                 sprintf(
                     'Expected array<string, mixed> for parameter "%s", got %s',
@@ -485,13 +490,13 @@ private static function parseUseStatements(string $file): array
             if ($result !== 1) {
                 continue;
             }
-            $useStatements[$matches['alias'] ?? $matches['class']] = ($matches['ns'] ?? '') . $matches['class'];
+            $useStatements[$matches['alias'] ?? $matches['class']] = $matches['ns'] . $matches['class'];
         }
         return $useStatements;
     }
 
     /**
-     * @return list<mixed> | array<string, mixed>
+     * @return list<mixed> | array<array-key, mixed>
      */
     private static function createConstructorArgumentForArrayType(
         ReflectionParameter $parameter,
@@ -500,14 +505,15 @@ private static function createConstructorArgumentForArrayType(
         if ($value === null && $parameter->allowsNull()) {
             return null;
         }
-        if (!is_array($value)) {
-            throw JsonError::decodeFailed(
-                sprintf('Expected array for parameter "%s", got %s', $parameter->getName(), gettype($value)),
-            );
+        if (is_array($value) && array_is_list($value)) {
+            return self::createConstructorArgumentForListType($parameter, $value);
         }
-        return array_is_list($value)
-            ? self::createConstructorArgumentForListType($parameter, $value)
-            : self::createConstructorArgumentForMapType($parameter, $value);
+        if ($value instanceof stdClass) {
+            return self::createConstructorArgumentForMapType($parameter, $value);
+        }
+        throw JsonError::decodeFailed(
+            sprintf('Expected array for parameter "%s", got %s', $parameter->getName(), gettype($value)),
+        );
     }
 
     /**
@@ -541,7 +547,7 @@ private static function createConstructorArgumentForListType(ReflectionParameter
             $items = [];
             /** @var mixed $item */
             foreach ($value as $item) {
-                if (!is_array($item)) {
+                if (!$item instanceof stdClass) {
                     throw JsonError::decodeFailed(
                         sprintf(
                             'Expected JSON objects for items in property "%s", got %s',
@@ -557,10 +563,9 @@ private static function createConstructorArgumentForListType(ReflectionParameter
     }
 
     /**
-     * @param array<array-key, mixed> $value
      * @return array<array-key, mixed>
      */
-    private static function createConstructorArgumentForMapType(ReflectionParameter $parameter, array $value): array
+    private static function createConstructorArgumentForMapType(ReflectionParameter $parameter, stdClass $value): array
     {
         $paramName = $parameter->getName();
         $valueType = self::getMapValueType($parameter);
@@ -577,17 +582,22 @@ private static function createConstructorArgumentForMapType(ReflectionParameter
             );
         }
         if (!class_exists($valueType)) {
-            return $value;
+            return (array)$value;
         }
         $result = [];
+        /**
+         * @var array-key $key
+         * @var mixed $value
+         * @phpstan-ignore-next-line foreach.nonIterable stdClass _is_ iterable
+         */
         foreach ($value as $key => $item) {
-            if (!is_array($item)) {
+            if (!$item instanceof stdClass) {
                 throw JsonError::decodeFailed(
                     sprintf(
                         'Expected an array for the value of key "%s" in parameter "%s", got %s',
                         $key,
                         $paramName,
-                        gettype($item),
+                        get_debug_type($item),
                     ),
                 );
             }
diff --git a/src/JsonError.php b/src/JsonError.php
index 83ddb3a..63ee9d4 100644
--- a/src/JsonError.php
+++ b/src/JsonError.php
@@ -9,7 +9,7 @@
 
 final class JsonError extends RuntimeException
 {
-    private function __construct(string $message = "", int $code = 0, Throwable|null $previous = null)
+    private function __construct(string $message = '', int $code = 0, Throwable|null $previous = null)
     {
         parent::__construct($message, $code, $previous);
     }
diff --git a/src/Parser/Location.php b/src/Parser/Location.php
new file mode 100644
index 0000000..9faf2fc
--- /dev/null
+++ b/src/Parser/Location.php
@@ -0,0 +1,12 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Eventjet\Json\Parser;
+
+final class Location
+{
+    public function __construct(public readonly int $line, public readonly int $column)
+    {
+    }
+}
diff --git a/src/Parser/Parser.php b/src/Parser/Parser.php
new file mode 100644
index 0000000..9237dcf
--- /dev/null
+++ b/src/Parser/Parser.php
@@ -0,0 +1,162 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Eventjet\Json\Parser;
+
+use stdClass;
+
+use function current;
+use function is_bool;
+use function is_float;
+use function is_int;
+use function is_string;
+use function next;
+use function sprintf;
+
+/**
+ * @phpstan-import-type TokenType from Tokenizer
+ * @phpstan-type JsonValue string | int | float | stdClass | list<mixed> | bool | null
+ */
+final class Parser
+{
+    /**
+     * @param list<TokenLocation> $tokens
+     */
+    public function __construct(private array &$tokens)
+    {
+    }
+
+    /**
+     * @return JsonValue
+     */
+    public static function parse(string $source): string|int|float|stdClass|array|bool|null
+    {
+        $tokens = Tokenizer::tokenize($source);
+        return (new self($tokens))->parseValue();
+    }
+
+    /**
+     * @return JsonValue
+     */
+    private function parseValue(): string|int|float|stdClass|array|bool|null
+    {
+        $token = current($this->tokens);
+        if ($token === false) {
+            throw SyntaxError::create('Unexpected end of input', 0, 0);
+        }
+        if ($token->token === null || is_string($token->token) || is_int($token->token) || is_float($token->token) || is_bool($token->token)) {
+            $this->next();
+            return $token->token;
+        }
+        return match ($token->token) {
+            Token::OpenCurly => $this->parseObject(),
+            Token::OpenBracket => $this->parseArray(),
+            default => throw SyntaxError::create(
+                sprintf('Unexpected token %s', Token::print($token->token)),
+                $token->location->start->line,
+                $token->location->start->column,
+            ),
+        };
+    }
+
+    private function parseObject(): stdClass
+    {
+        $object = new stdClass();
+        $this->next();
+        $first = true;
+        while (true) {
+            $token = current($this->tokens);
+            if ($token === false) {
+                throw SyntaxError::create('Unexpected end of input', 0, 0);
+            }
+            if ($token->token === Token::CloseCurly) {
+                $this->next();
+                return $object;
+            }
+            if (!$first) {
+                if ($token->token !== Token::Comma) {
+                    throw SyntaxError::create(
+                        sprintf('Expected comma, got %s', Token::print($token->token)),
+                        $token->location->start->line,
+                        $token->location->start->column,
+                    );
+                }
+                $this->next();
+            }
+            [$key, $value] = $this->parseObjectPair();
+            $object->$key = $value;
+            $first = false;
+        }
+    }
+
+    /**
+     * @return list<mixed>
+     */
+    private function parseArray(): array
+    {
+        $array = [];
+        $this->next();
+        $first = true;
+        while (true) {
+            $token = current($this->tokens);
+            if ($token === false) {
+                throw SyntaxError::create('Unexpected end of input', 0, 0);
+            }
+            if ($token->token === Token::CloseBracket) {
+                $this->next();
+                return $array;
+            }
+            if (!$first) {
+                if ($token->token !== Token::Comma) {
+                    throw SyntaxError::create(
+                        sprintf('Expected comma, got %s', Token::print($token->token)),
+                        $token->location->start->line,
+                        $token->location->start->column,
+                    );
+                }
+                $this->next();
+            }
+            $array[] = $this->parseValue();
+            $first = false;
+        }
+    }
+
+    private function next(): void
+    {
+        next($this->tokens);
+    }
+
+    /**
+     * @return array{string, JsonValue}
+     */
+    private function parseObjectPair(): array
+    {
+        $token = current($this->tokens);
+        if ($token === false) {
+            throw SyntaxError::create('Unexpected end of input', 0, 0);
+        }
+        if (!is_string($token->token)) {
+            throw SyntaxError::create(
+                sprintf('Expected string, got %s', Token::print($token->token)),
+                $token->location->start->line,
+                $token->location->start->column,
+            );
+        }
+        $key = $token->token;
+        $this->next();
+        $token = current($this->tokens);
+        if ($token === false) {
+            throw SyntaxError::create('Unexpected end of input', 0, 0);
+        }
+        if ($token->token !== Token::Colon) {
+            throw SyntaxError::create(
+                sprintf('Expected colon, got %s', Token::print($token->token)),
+                $token->location->start->line,
+                $token->location->start->column,
+            );
+        }
+        $this->next();
+        return [$key, $this->parseValue()];
+    }
+}
diff --git a/src/Parser/Span.php b/src/Parser/Span.php
new file mode 100644
index 0000000..47d6bcb
--- /dev/null
+++ b/src/Parser/Span.php
@@ -0,0 +1,31 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Eventjet\Json\Parser;
+
+final class Span
+{
+    /**
+     * @psalm-suppress PossiblyUnusedProperty We'll use it later. Also, Psalm doesn't let use suppress this for promoted
+     *     properties, that's why it isn't promoted.
+     */
+    public readonly Location $end;
+
+    private function __construct(
+        public readonly Location $start,
+        Location $end,
+    ) {
+        $this->end = $end;
+    }
+
+    public static function create(int $startLine, int $startColumn, int $endLine, int $endColumn): self
+    {
+        return new self(new Location($startLine, $startColumn), new Location($endLine, $endColumn));
+    }
+
+    public static function char(int $line, int $column): self
+    {
+        return new self(new Location($line, $column), new Location($line, $column));
+    }
+}
diff --git a/src/Parser/SyntaxError.php b/src/Parser/SyntaxError.php
new file mode 100644
index 0000000..258989c
--- /dev/null
+++ b/src/Parser/SyntaxError.php
@@ -0,0 +1,20 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Eventjet\Json\Parser;
+
+use RuntimeException;
+
+final class SyntaxError extends RuntimeException
+{
+    private function __construct(string $message, public readonly Location $location)
+    {
+        parent::__construct($message);
+    }
+
+    public static function create(string $message, int $line, int $column): self
+    {
+        return new self($message, new Location($line, $column));
+    }
+}
diff --git a/src/Parser/Token.php b/src/Parser/Token.php
new file mode 100644
index 0000000..3fe9413
--- /dev/null
+++ b/src/Parser/Token.php
@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Eventjet\Json\Parser;
+
+use function is_string;
+use function str_replace;
+
+enum Token: string
+{
+    case OpenCurly = '{';
+    case CloseCurly = '}';
+    case Colon = ':';
+    case Comma = ',';
+    case OpenBracket = '[';
+    case CloseBracket = ']';
+
+    public static function print(self|string|bool|int|float|null $token): string
+    {
+        if ($token instanceof self) {
+            return $token->value;
+        }
+        if (is_string($token)) {
+            return '"' . str_replace(['\\', "\n"], ['\\\\', '\n'], $token) . '"';
+        }
+        return match ($token) {
+            null => 'null',
+            true => 'true',
+            false => 'false',
+            default => (string)$token,
+        };
+    }
+}
diff --git a/src/Parser/TokenLocation.php b/src/Parser/TokenLocation.php
new file mode 100644
index 0000000..118abc3
--- /dev/null
+++ b/src/Parser/TokenLocation.php
@@ -0,0 +1,14 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Eventjet\Json\Parser;
+
+final class TokenLocation
+{
+    public function __construct(
+        public readonly Token|string|int|float|bool|null $token,
+        public readonly Span $location,
+    ) {
+    }
+}
diff --git a/src/Parser/Tokenizer.php b/src/Parser/Tokenizer.php
new file mode 100644
index 0000000..db05efa
--- /dev/null
+++ b/src/Parser/Tokenizer.php
@@ -0,0 +1,249 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Eventjet\Json\Parser;
+
+use function array_shift;
+use function current;
+use function mb_str_split;
+use function sprintf;
+
+/**
+ * @phpstan-type TokenType Token | string | int | float | bool | null
+ */
+final class Tokenizer
+{
+    private const OPEN_CURLY = '{';
+    private const CLOSE_CURLY = '}';
+    private const COLON = ':';
+    private const COMMA = ',';
+    private const OPEN_BRACKET = '[';
+    private const CLOSE_BRACKET = ']';
+    private const QUOTE = '"';
+    private const BACKSLASH = '\\';
+
+    private int $line = 1;
+    private int $column = 1;
+
+    /**
+     * @param list<string> $chars
+     */
+    public function __construct(private array &$chars)
+    {
+    }
+
+    /**
+     * @return list<TokenLocation>
+     */
+    public static function tokenize(string $source): array
+    {
+        $chars = mb_str_split($source);
+        return (new self($chars))->doTokenize();
+    }
+
+    /**
+     * @return list<TokenLocation>
+     */
+    private function doTokenize(): array
+    {
+        $tokens = [];
+        while (true) {
+            $this->skipWhitespace();
+            $char = current($this->chars);
+            if ($char === false) {
+                return $tokens;
+            }
+            $token = match ($char) {
+                self::OPEN_CURLY => Token::OpenCurly,
+                self::CLOSE_CURLY => Token::CloseCurly,
+                self::COLON => Token::Colon,
+                self::COMMA => Token::Comma,
+                self::OPEN_BRACKET => Token::OpenBracket,
+                self::CLOSE_BRACKET => Token::CloseBracket,
+                default => null,
+            };
+            if ($token !== null) {
+                $tokens[] = self::charToken($token);
+                $this->next();
+                continue;
+            }
+            if ($char === self::QUOTE) {
+                $tokens[] = $this->readString();
+                continue;
+            }
+            if ($char === '-' || ($char >= '0' && $char <= '9')) {
+                $tokens[] = $this->readNumber();
+                continue;
+            }
+            if ($char === 't') {
+                $this->expect('t');
+                $this->expect('r');
+                $this->expect('u');
+                $this->expect('e');
+                $tokens[] = new TokenLocation(true, Span::create($this->line, $this->column - 4, $this->line, $this->column));
+                continue;
+            }
+            if ($char === 'f') {
+                $this->expect('f');
+                $this->expect('a');
+                $this->expect('l');
+                $this->expect('s');
+                $this->expect('e');
+                $tokens[] = new TokenLocation(false, Span::create($this->line, $this->column - 5, $this->line, $this->column));
+                continue;
+            }
+            if ($char === 'n') {
+                $this->expect('n');
+                $this->expect('u');
+                $this->expect('l');
+                $this->expect('l');
+                $tokens[] = new TokenLocation(null, Span::create($this->line, $this->column - 3, $this->line, $this->column));
+                continue;
+            }
+            throw SyntaxError::create(
+                sprintf("Unexpected character '%s' at line %d, column %d", $char, $this->line, $this->column),
+                $this->line,
+                $this->column,
+            );
+        }
+    }
+
+    private function readString(): TokenLocation
+    {
+        $startLine = $this->line;
+        $startColumn = $this->column;
+        $string = '';
+        $this->expect(self::QUOTE);
+        while (true) {
+            $char = current($this->chars);
+            if ($char === self::QUOTE) {
+                $this->next();
+                break;
+            }
+            if ($char === false) {
+                throw SyntaxError::create(
+                    sprintf('Unexpected end of input at line %d, column %d', $this->line, $this->column),
+                    $this->line,
+                    $this->column,
+                );
+            }
+            if ($char !== self::BACKSLASH) {
+                $string .= $char;
+                $this->next();
+                continue;
+            }
+            $this->next();
+            $char = current($this->chars);
+            $string .= match ($char) {
+                false => throw SyntaxError::create(
+                    sprintf('Unexpected end of input at line %d, column %d', $this->line, $this->column),
+                    $this->line,
+                    $this->column,
+                ),
+                'n' => "\n",
+                default => $char,
+            };
+            $this->next();
+        }
+        return new TokenLocation($string, Span::create($startLine, $startColumn, $this->line, $this->column));
+    }
+
+    private function next(): void
+    {
+        $shifted = array_shift($this->chars);
+        if ($shifted === "\n") {
+            $this->line++;
+            $this->column = 1;
+        } else {
+            $this->column++;
+        }
+    }
+
+    private function expect(string $char): void
+    {
+        $current = current($this->chars);
+        if ($current !== $char) {
+            throw SyntaxError::create(
+                sprintf("Expected '%s' but got '%s'", $char, $current),
+                $this->line,
+                $this->column,
+            );
+        }
+        $this->next();
+    }
+
+    private function readNumber(): TokenLocation
+    {
+        $startLine = $this->line;
+        $startColumn = $this->column;
+        $isFloat = false;
+        $number = '';
+        $char = current($this->chars);
+        if ($char === '-') {
+            $number .= '-';
+            $this->next();
+        }
+        $char = current($this->chars);
+        if ($char === '0') {
+            $number .= '0';
+            $this->next();
+        } else {
+            $number .= $this->readDigits();
+        }
+        $char = current($this->chars);
+        if ($char === '.') {
+            $isFloat = true;
+            $number .= '.';
+            $this->next();
+            $number .= $this->readDigits();
+        }
+        $char = current($this->chars);
+        if ($char === 'e' || $char === 'E') {
+            $number .= 'e';
+            $this->next();
+            $char = current($this->chars);
+            if ($char === '+' || $char === '-') {
+                $number .= $char;
+                $this->next();
+            }
+            $number .= $this->readDigits();
+        }
+        $token = $isFloat ? (float)$number : (int)$number;
+        return new TokenLocation($token, Span::create($startLine, $startColumn, $this->line, $this->column));
+    }
+
+    private function readDigits(): string
+    {
+        $digits = '';
+        while (true) {
+            $char = current($this->chars);
+            if ($char === false) {
+                break;
+            }
+            if ($char < '0' || $char > '9') {
+                break;
+            }
+            $digits .= $char;
+            $this->next();
+        }
+        return $digits;
+    }
+
+    private function skipWhitespace(): void
+    {
+        while (true) {
+            $char = current($this->chars);
+            if ($char === ' ' || $char === "\t" || $char === "\n" || $char === "\r") {
+                $this->next();
+                continue;
+            }
+            break;
+        }
+    }
+
+    private function charToken(Token|string|int|float|bool|null $token): TokenLocation
+    {
+        return new TokenLocation($token, Span::char($this->line, $this->column));
+    }
+}
diff --git a/src/Type/JsonType.php b/src/Type/JsonType.php
index d55efde..12cf0fe 100644
--- a/src/Type/JsonType.php
+++ b/src/Type/JsonType.php
@@ -70,7 +70,7 @@ public static function union(self $first, self $second, self ...$other): Union
 
     protected static function joinPath(string $prefix, string|int $key): string
     {
-        return $prefix === '' ? (string)$key : sprintf("%s.%d", $prefix, $key);
+        return $prefix === '' ? (string)$key : sprintf('%s.%d', $prefix, $key);
     }
 
     /**
diff --git a/tests/unit/Fixtures/InvalidArrayConstructorParamTag.php b/tests/unit/Fixtures/InvalidArrayConstructorParamTag.php
index f8c4ee9..32f35a0 100644
--- a/tests/unit/Fixtures/InvalidArrayConstructorParamTag.php
+++ b/tests/unit/Fixtures/InvalidArrayConstructorParamTag.php
@@ -9,10 +9,11 @@
 final class InvalidArrayConstructorParamTag
 {
     /**
+     * @phpstan-ignore-next-line phpDoc.parseError
      * @param class-string<DateTimeImmutable> items This is not a valid param tag
      * @param list<DateTimeImmutable> $items
      */
-    public function __construct(public readonly array $items) // @phpstan-ignore-line
+    public function __construct(public readonly array $items)
     {
     }
 }
diff --git a/tests/unit/Parser/ParserTest.php b/tests/unit/Parser/ParserTest.php
new file mode 100644
index 0000000..52caa15
--- /dev/null
+++ b/tests/unit/Parser/ParserTest.php
@@ -0,0 +1,99 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Eventjet\Test\Unit\Json\Parser;
+
+use Eventjet\Json\Parser\Parser;
+use Eventjet\Json\Parser\SyntaxError;
+use PHPUnit\Framework\TestCase;
+
+use function json_decode;
+use function json_encode;
+
+final class ParserTest extends TestCase
+{
+    /**
+     * @return iterable<string, array{string}>
+     */
+    public static function provideValidJsonStrings(): iterable
+    {
+        $cases = [
+            'true',
+            'false',
+            '""',
+            '"foo"',
+            '0',
+            '42',
+            '-42',
+            '3.14',
+            '-3.14',
+            '[]',
+            '{}',
+            '["foo"]',
+            '["foo", "bar"]',
+            '{"foo": "bar"}',
+            '{"foo": "bar", "baz": "qux"}',
+        ];
+        foreach ($cases as $case) {
+            yield $case => [$case];
+        }
+    }
+
+    /**
+     * @return iterable<string, array{string}>
+     */
+    public static function provideInvalidJsonStrings(): iterable
+    {
+        $cases = [
+            '',
+            '{',
+            '}',
+            '{"foo"',
+            '{"foo":',
+            '{"foo": "bar"',
+            '{"foo": "bar",',
+            '{"foo": "bar" "baz": "qux"}',
+            '{: "foo"}',
+            '{null: "foo"}',
+            '{true: "foo"}',
+            '{false: "foo"}',
+            '{0: "foo"}',
+            '{42: "foo"}',
+            '{-42: "foo"}',
+            '{3.14: "foo"}',
+            '{-3.14: "foo"}',
+            '{[]: "foo"}',
+            '[',
+            ']',
+            '["foo"',
+            '["foo",',
+            '["foo" "bar"]',
+        ];
+        foreach ($cases as $case) {
+            yield $case => [$case];
+        }
+    }
+
+    /**
+     * @dataProvider provideValidJsonStrings
+     */
+    public function testParityWithNativeFunction(string $json): void
+    {
+        $actual = Parser::parse($json);
+        /** @var mixed $expected */
+        $expected = json_decode($json);
+
+        self::assertSame(json_encode($expected), json_encode($actual));
+    }
+
+    /**
+     * @dataProvider provideInvalidJsonStrings
+     */
+    public function testInvalidJsonThrowsException(string $json): void
+    {
+        $this->expectException(SyntaxError::class);
+
+        Parser::parse($json);
+    }
+}
diff --git a/tests/unit/Parser/TokenizerTest.php b/tests/unit/Parser/TokenizerTest.php
new file mode 100644
index 0000000..67de3d0
--- /dev/null
+++ b/tests/unit/Parser/TokenizerTest.php
@@ -0,0 +1,101 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Eventjet\Test\Unit\Json\Parser;
+
+use Eventjet\Json\Parser\SyntaxError;
+use Eventjet\Json\Parser\Token;
+use Eventjet\Json\Parser\Tokenizer;
+use Eventjet\Json\Parser\TokenLocation;
+use PHPUnit\Framework\TestCase;
+
+use function array_map;
+use function implode;
+use function json_decode;
+use function json_encode;
+
+final class TokenizerTest extends TestCase
+{
+    /**
+     * @return iterable<string, array{string}>
+     */
+    public static function provideValidJsonStrings(): iterable
+    {
+        $cases = [
+            'null',
+            'true',
+            'false',
+            '""',
+            '"foo"',
+            '0',
+            '42',
+            '-42',
+            '3.14',
+            '-3.14',
+            '[]',
+            '{}',
+            '["foo"]',
+            '["foo", "bar"]',
+            '{"foo": "bar"}',
+            '{"foo": "bar", "baz": "qux"}',
+            '{"a": null, "b": true, "c": false, "d": "", "e": 0, "f": 42, "g": -42, "h": 3.14, "i": -3.14}',
+            <<<JSON
+                "a\\\\b\\nc"
+                JSON,
+            ' { "foo" : [ "bar", 42 ] , "bar" : null }',
+            '10e3',
+            '10E+5',
+        ];
+        foreach ($cases as $case) {
+            yield $case => [$case];
+        }
+    }
+
+    /**
+     * @return iterable<string, array{string, int, int}>
+     */
+    public static function provideInvalidJsonStrings(): iterable
+    {
+        $cases = [
+            'unexpected' => [1, 1],
+            '"unterminated string' => [1, 21],
+            '"unterminated string\\' => [1, 22],
+            "{\n  missingstartquote\" => \"\" }" => [2, 3],
+        ];
+        foreach ($cases as $case => [$line, $column]) {
+            yield $case => [$case, $line, $column];
+        }
+    }
+
+    /**
+     * @dataProvider provideValidJsonStrings
+     */
+    public function testMatchesNative(string $json): void
+    {
+        $expected = json_encode(json_decode($json));
+        $actual = implode(
+            '',
+            array_map(
+                static fn(TokenLocation $token) => Token::print($token->token),
+                Tokenizer::tokenize($json),
+            ),
+        );
+
+        self::assertSame($expected, $actual);
+    }
+
+    /**
+     * @dataProvider provideInvalidJsonStrings
+     */
+    public function testSyntaxError(string $json, int $line, int $column): void
+    {
+        try {
+            Tokenizer::tokenize($json);
+            self::fail('Expected a SyntaxError');
+        } catch (SyntaxError $e) {
+            self::assertSame($line, $e->location->line);
+            self::assertSame($column, $e->location->column);
+        }
+    }
+}