• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

azjezz / psl / 22950470912

11 Mar 2026 11:31AM UTC coverage: 98.564% (+0.01%) from 98.551%
22950470912

Pull #618

github

web-flow
Merge 86ce2787f into 6ac0675ad
Pull Request #618: feat(type): add `nullish()` type for optional-and-nullable shape fields

17 of 18 new or added lines in 3 files covered. (94.44%)

9538 of 9677 relevant lines covered (98.56%)

34.89 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

99.12
/src/Psl/Type/Internal/ShapeType.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace Psl\Type\Internal;
6

7
use Override;
8
use Psl\Type;
9
use Psl\Type\Exception\AssertException;
10
use Psl\Type\Exception\CoercionException;
11
use stdClass;
12
use Throwable;
13

14
use function array_diff_key;
15
use function array_filter;
16
use function array_intersect_key;
17
use function array_keys;
18
use function implode;
19
use function is_array;
20
use function is_int;
21
use function is_iterable;
22

23
/**
24
 * @template Tk of array-key
25
 * @template Tv
26
 *
27
 * @extends Type\Type<array<Tk, Tv>>
28
 *
29
 * @mago-expect lint:kan-defect
30
 */
31
final readonly class ShapeType extends Type\Type
32
{
33
    /**
34
     * @var array<Tk, Type\TypeInterface<Tv>>
35
     */
36
    private array $requiredElements;
37

38
    /**
39
     * @psalm-mutation-free
40
     *
41
     * @param array<Tk, Type\TypeInterface<Tv>> $elements_types
42
     */
43
    public function __construct(
44
        private array $elements_types,
45
        private bool $allow_unknown_fields = false,
46
    ) {
47
        $this->requiredElements = array_filter(
158✔
48
            $elements_types,
158✔
49
            static fn(Type\TypeInterface $element): bool => !$element->isOptional(),
158✔
50
        );
158✔
51
    }
52

53
    /**
54
     * @psalm-assert-if-true array<Tk, Tv> $value
55
     */
56
    #[Override]
57
    public function matches(mixed $value): bool
58
    {
59
        if (!is_array($value)) {
52✔
60
            return false;
22✔
61
        }
62

63
        foreach ($this->elements_types as $element => $type) {
30✔
64
            if (array_key_exists($element, $value)) {
30✔
65
                if (!$type->matches($value[$element])) {
30✔
66
                    return false;
8✔
67
                }
68

69
                continue;
30✔
70
            }
71

72
            if (!$type->isOptional()) {
22✔
73
                return false;
8✔
74
            }
75
        }
76

77
        if (!$this->allow_unknown_fields) {
18✔
78
            foreach ($value as $k => $_v) {
17✔
79
                if (!array_key_exists($k, $this->elements_types)) {
17✔
80
                    return false;
1✔
81
                }
82
            }
83
        }
84

85
        return true;
17✔
86
    }
87

88
    /**
89
     * @throws CoercionException
90
     *
91
     * @return array<Tk, Tv>
92
     */
93
    #[Override]
94
    public function coerce(mixed $value): array
95
    {
96
        if ($value instanceof stdClass) {
71✔
97
            $value = (array) $value;
2✔
98
        }
99

100
        // To whom reads this: yes, I hate this stuff as passionately as you do :-)
101
        if (!is_array($value)) {
71✔
102
            // Fallback to slow implementation - unhappy path
103
            return $this->coerceIterable($value);
27✔
104
        }
105

106
        if (array_keys(array_intersect_key($value, $this->requiredElements)) !== array_keys($this->requiredElements)) {
50✔
107
            // Fallback to slow implementation - unhappy path
108
            return $this->coerceIterable($value);
12✔
109
        }
110

111
        if (!$this->allow_unknown_fields && array_keys($value) !== array_keys($this->elements_types)) {
43✔
112
            // Fallback to slow implementation - unhappy path
113
            return $this->coerceIterable($value);
18✔
114
        }
115

116
        $coerced = [];
39✔
117

118
        try {
119
            foreach (array_intersect_key($this->elements_types, $value) as $key => $type) {
39✔
120
                $coerced[$key] = $type->coerce($value[$key]);
39✔
121
            }
122
        } catch (CoercionException) {
9✔
123
            // Fallback to slow implementation - unhappy path. Prevents having to eagerly compute traces.
124
            $this->coerceIterable($value);
9✔
125
        }
126

127
        foreach ($this->elements_types as $key => $type) {
30✔
128
            if (!array_key_exists($key, $coerced) && $type instanceof NullishType) {
30✔
NEW
129
                $coerced[$key] = null;
×
130
            }
131
        }
132

133
        /** @var mixed $additionalValue */
134
        foreach (array_diff_key($value, $this->elements_types) as $key => $additionalValue) {
30✔
135
            $coerced[$key] = $additionalValue;
1✔
136
        }
137

138
        return $coerced;
30✔
139
    }
140

141
    /**
142
     * @throws CoercionException
143
     *
144
     * @return array<Tk, Tv>
145
     */
146
    private function coerceIterable(mixed $value): array
147
    {
148
        if (!is_iterable($value)) {
56✔
149
            throw CoercionException::withValue($value, $this->toString());
14✔
150
        }
151

152
        $arrayKeyType = Type\array_key();
42✔
153
        $array = [];
42✔
154
        $k = null;
42✔
155
        try {
156
            /**
157
             * @var Tk $k
158
             * @var Tv $v
159
             */
160
            foreach ($value as $k => $v) {
42✔
161
                // @mago-expect analysis:redundant-type-comparison
162
                if (!$arrayKeyType->matches($k)) {
39✔
163
                    continue;
2✔
164
                }
165

166
                $array[$k] = $v;
37✔
167
            }
168
        } catch (Throwable $e) {
3✔
169
            throw CoercionException::withValue(null, $this->toString(), PathExpression::iteratorError($k), $e);
3✔
170
        }
171

172
        $result = [];
39✔
173
        $element = null;
39✔
174
        $element_value_found = false;
39✔
175

176
        try {
177
            foreach ($this->elements_types as $element => $type) {
39✔
178
                $element_value_found = false;
39✔
179
                if (array_key_exists($element, $array)) {
39✔
180
                    $element_value_found = true;
36✔
181
                    $result[$element] = $type->coerce($array[$element]);
36✔
182

183
                    continue;
34✔
184
                }
185

186
                if ($type->isOptional()) {
30✔
187
                    if ($type instanceof NullishType) {
16✔
188
                        $result[$element] = null;
2✔
189
                    }
190

191
                    continue;
16✔
192
                }
193

194
                throw CoercionException::withValue(null, $this->toString(), PathExpression::path($element));
14✔
195
            }
196
        } catch (CoercionException $e) {
18✔
197
            throw match (true) {
198
                $element_value_found => CoercionException::withValue(
18✔
199
                    null === $element ? null : $array[$element] ?? null,
18✔
200
                    $this->toString(),
18✔
201
                    PathExpression::path($element),
18✔
202
                    $e,
18✔
203
                ),
18✔
204
                default => $e,
14✔
205
            };
206
        }
207

208
        if ($this->allow_unknown_fields) {
21✔
209
            foreach ($array as $k => $v) {
1✔
210
                if (array_key_exists($k, $result)) {
1✔
211
                    continue;
1✔
212
                }
213

214
                $result[$k] = $v;
1✔
215
            }
216
        }
217

218
        return $result;
21✔
219
    }
220

221
    /**
222
     * @throws AssertException
223
     *
224
     * @return array<Tk, Tv>
225
     *
226
     * @psalm-assert array<Tk, Tv> $value
227
     */
228
    #[Override]
229
    public function assert(mixed $value): array
230
    {
231
        if (!is_array($value)) {
60✔
232
            throw AssertException::withValue($value, $this->toString());
22✔
233
        }
234

235
        $result = [];
38✔
236
        $element = null;
38✔
237
        $element_value_found = false;
38✔
238

239
        try {
240
            foreach ($this->elements_types as $element => $type) {
38✔
241
                $element_value_found = false;
38✔
242
                if (array_key_exists($element, $value)) {
38✔
243
                    $element_value_found = true;
37✔
244
                    $result[$element] = $type->assert($value[$element]);
37✔
245

246
                    continue;
34✔
247
                }
248

249
                if ($type->isOptional()) {
24✔
250
                    if ($type instanceof NullishType) {
15✔
251
                        $result[$element] = null;
1✔
252
                    }
253

254
                    continue;
15✔
255
                }
256

257
                throw AssertException::withValue(null, $this->toString(), PathExpression::path($element));
9✔
258
            }
259
        } catch (AssertException $e) {
16✔
260
            throw match (true) {
261
                $element_value_found => AssertException::withValue(
16✔
262
                    null === $element ? null : $value[$element] ?? null,
16✔
263
                    $this->toString(),
16✔
264
                    PathExpression::path($element),
16✔
265
                    $e,
16✔
266
                ),
16✔
267
                default => $e,
9✔
268
            };
269
        }
270

271
        /**
272
         * @var Tv $v
273
         */
274
        foreach ($value as $k => $v) {
22✔
275
            if (array_key_exists($k, $result)) {
22✔
276
                continue;
22✔
277
            }
278

279
            if ($this->allow_unknown_fields) {
2✔
280
                $result[$k] = $v;
1✔
281
                continue;
1✔
282
            }
283

284
            throw AssertException::withValue($v, $this->toString(), PathExpression::path($k));
1✔
285
        }
286

287
        return $result;
21✔
288
    }
289

290
    /**
291
     * Returns a string representation of the shape.
292
     */
293
    #[Override]
294
    public function toString(): string
295
    {
296
        $nodes = [];
80✔
297
        foreach ($this->elements_types as $element => $type) {
80✔
298
            $nodes[] = $this->getElementName($element) . ($type->isOptional() ? '?' : '') . ': ' . $type->toString();
80✔
299
        }
300

301
        return 'array{' . implode(', ', $nodes) . '}';
80✔
302
    }
303

304
    private function getElementName(string|int $element): string
305
    {
306
        return is_int($element) ? (string) $element : '\'' . $element . '\'';
80✔
307
    }
308
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc