levelmax World 2 Honest Types
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. B
L
Level 2.4 ~12 min Target: PHPStan level 7

Type aliases: name the contract

An order line is array{sku: non-empty-string, quantity: positive-int, pence: int<0, max>}. The basket builds it, the invoice totals it, and the warehouse picks it. If each of them writes that shape out, you have three contracts that only agree until someone changes one of them.

Someone always does. They rename qty to quantity in the basket, the invoice still reads qty, and every line comes to £0.

Name it once

@phpstan-type gives a type a name, on a class's docblock:

/**
 * @phpstan-type Address array{line1: non-empty-string, postcode: non-empty-string, country: 'GB'|'IE'}
 */
final class CustomerProfile
{
    /** @return Address */
    public function deliveryAddress(): array

Any other class can import it by name:

/**
 * @phpstan-import-type Address from CustomerProfile
 */
final class LabelPrinter
{
    /** @param Address $to */
    public function print(array $to): string

Now there's one definition. Change it, and PHPStan checks every class that imports it. If a name clashes, rename it on the way in: @phpstan-import-type Address from CustomerProfile as ShippingAddress.

An alias is just a name for a type. It doesn't exist at runtime, and it's still an array underneath. It can be any type, not only a shape: @phpstan-type Skus non-empty-list<non-empty-string> works too.

Who owns it?

Put the alias on the class that produces the data, not on whichever class needed it first. The producer decides what an order line contains. Everyone else consumes it, so they import it. When the shape needs to change, there's one obvious place to change it.

Power-up

Before
<?php

final class CustomerProfile
{
    /** @return array{line1: string, postcode: string, country: string} */
    public function deliveryAddress(): array { /* ... */ }
}

final class LabelPrinter
{
    /** @param array{line1: string, postCode: string} $to   copied once, then drifted */
    public function print(array $to): string { /* ... */ }
}
After
<?php

/**
 * @phpstan-type Address array{line1: non-empty-string, postcode: non-empty-string, country: 'GB'|'IE'}
 */
final class CustomerProfile
{
    /** @return Address */
    public function deliveryAddress(): array { /* ... */ }
}

/**
 * @phpstan-import-type Address from CustomerProfile
 */
final class LabelPrinter
{
    /** @param Address $to */
    public function print(array $to): string { /* ... */ }
}

Quick check

1. Basket builds order lines, and Invoice and PickList read them. Where should the OrderLine alias be defined?
2. PHPStan reports that Invoice::total() expects lines with qty, but is given lines with quantity. What's the best fix?
3. What does @phpstan-import-type OrderLine from Basket as Line do?

CHALLENGE

Basket, Invoice and PickList each keep their own copy of the order-line shape. Someone renamed qty to quantity in Basket, and Invoice now bills every line at £0.

Define the shape once, as an OrderLine alias on Basket with refined types, and import it into Invoice and PickList instead of copying it. The tests and PHPStan at level 7 should both pass.

Running on PHP 8.3…

Run the tests, run PHPStan, or submit for both. Ctrl/⌘ + Enter submits from the editor.