Recently my pull request was merged which adds non-falsy-string to the PHPStan type system.

Whats the problem?

Until PHPStan 1.8.2 we faced the problem that the existing non-empty-string did not exclude '0', which is kind of a magic string in PHP.

While it makes sense in some situations like

var_dump((bool) '0'); // bool(false)
var_dump((bool) '1'); // bool(true)

there are some really weird cases, e.g. when checking for emptiness:

var_dump(empty('')); // bool(true)
var_dump(empty('0')); // bool(true)
var_dump(empty('1')); // bool(false)

The important point is, that a string, if it contains just '0' is considered empty.

In PHPStan 1.8.2 this leads to unexpected errors (PHPStan Bug 5370, PHPStan Bug 5317) , see e.g.

because most of the type-system used non-empty-string which might include a '0'.

non-falsy-string to the rescue

Considering these edge cases, it makes sense to establish a non-falsy-string value. Its effectively a subtype of non-empty-string, but it excludes '0'.

In other words, the upcoming PHPStan release (I guess it will be 1.9.0; might be 1.8.3 though) can handle this cases better and no longer produces the mentioned false positive errors.

For those interested in the details can see the corresponding NodeScopeResolverTest, which shows us how a non-falsy-string intersects/interacts with other existing types and what to expect:

the first argument to assertType shows the resulting return-type of the expression given as second argument

<?php

namespace NonFalseyString;

use function PHPStan\Testing\assertType;

class Foo {
  /**
   * @param non-falsy-string $nonFalseyString
   */
  public function bar($nonFalseyString) {
    assertType('int<min, -1>|int<1, max>', (int) $nonFalseyString);
  }

  /**
   * @param numeric-string $s
   */
  function removeZero(string $s) {
    if ($s === '0') {
      return;
    }

    assertType('non-falsy-string', $s);
  }

  /**
   * @param non-empty-string $nonEmpty
   */
  public function doBar5(string $s, $nonEmpty): void
  {
    if (2 <= strlen($s)) {
      assertType('non-falsy-string', $s);
    }
    assertType('string', $s);

    if (3 === strlen($s)) {
      assertType('non-falsy-string', $s);
    }
    assertType('string', $s);

    if (2 <= strlen($nonEmpty)) {
      assertType('non-falsy-string', $nonEmpty);
    }
  }

  /**
   * @param numeric-string $numericS
   * @param non-falsy-string $nonFalsey
   * @param non-empty-string $nonEmpty
   * @param literal-string $literalString
   */
  function concat(string $s, string $nonFalsey, $numericS, $nonEmpty, $literalString): void
  {
    assertType('non-falsy-string', $nonFalsey . '');
    assertType('non-falsy-string', $nonFalsey . $s);

    assertType('non-falsy-string', $nonFalsey . $nonEmpty);
    assertType('non-falsy-string', $nonEmpty . $nonFalsey);

    assertType('non-falsy-string', $nonEmpty . $nonEmpty);

    assertType('non-falsy-string', $nonFalsey . $literalString);
    assertType('non-falsy-string', $literalString . $nonFalsey);

    assertType('non-falsy-string', $nonFalsey . $numericS);
    assertType('non-falsy-string', $numericS . $nonFalsey);

    assertType('non-falsy-string', $nonEmpty . $numericS);
    assertType('non-falsy-string', $numericS . $nonEmpty);
  }

  /**
   * @param non-falsy-string $nonFalsey
   * @param non-empty-array<non-falsy-string> $arrayOfNonFalsey
   * @param non-empty-array $nonEmptyArray
   */
  function stringFunctions(string $s, $nonFalsey, $arrayOfNonFalsey, $nonEmptyArray)
  {
    assertType('string', implode($nonFalsey, []));
    assertType('non-falsy-string', implode($nonFalsey, $nonEmptyArray));
    assertType('non-falsy-string', implode($nonFalsey, $arrayOfNonFalsey));
    assertType('non-falsy-string', implode($s, $arrayOfNonFalsey));

    assertType('non-falsy-string', addslashes($nonFalsey));
    assertType('non-falsy-string', addcslashes($nonFalsey));

    assertType('non-falsy-string', escapeshellarg($nonFalsey));
    assertType('non-falsy-string', escapeshellcmd($nonFalsey));

    assertType('non-falsy-string', strtoupper($nonFalsey));
    assertType('non-falsy-string', strtolower($nonFalsey));
    assertType('non-falsy-string', mb_strtoupper($nonFalsey));
    assertType('non-falsy-string', mb_strtolower($nonFalsey));
    assertType('non-falsy-string', lcfirst($nonFalsey));
    assertType('non-falsy-string', ucfirst($nonFalsey));
    assertType('non-falsy-string', ucwords($nonFalsey));
    assertType('non-falsy-string', htmlspecialchars($nonFalsey));
    assertType('non-falsy-string', htmlentities($nonFalsey));

    assertType('non-falsy-string', urlencode($nonFalsey));
    assertType('non-falsy-string', urldecode($nonFalsey));
    assertType('non-falsy-string', rawurlencode($nonFalsey));
    assertType('non-falsy-string', rawurldecode($nonFalsey));

    assertType('non-falsy-string', preg_quote($nonFalsey));

    assertType('non-falsy-string', sprintf($nonFalsey));
    assertType('non-falsy-string', vsprintf($nonFalsey, []));

    assertType('int<1, max>', strlen($nonFalsey));

    assertType('non-falsy-string', str_pad($nonFalsey, 0));
    assertType('non-falsy-string', str_repeat($nonFalsey, 1));

  }

  /**
   * @param non-falsy-string $nonFalsey
   * @param positive-int $positiveInt
   * @param 1|2|3 $positiveRange
   * @param -1|-2|-3 $negativeRange
   */
  public function doSubstr($nonFalsey, $positiveInt, $positiveRange, $negativeRange): void
  {
    assertType('non-falsy-string', substr($nonFalsey, -5));
    assertType('non-falsy-string', substr($nonFalsey, $negativeRange));

    assertType('non-falsy-string', substr($nonFalsey, 0, 5));
    assertType('non-falsy-string', substr($nonFalsey, 0, $positiveRange));

    assertType('non-falsy-string', substr($nonFalsey, 0, $positiveInt));
  }
}

Cross tool compatibility

In Psalm the non-falsy-string type is available since 4.5.0 with the same semantics.

Discussing the new addition on Twitter lead some interesting results. The double negative name non-falsy-string is pretty hard to reason about, so we came to the conclusion to add a truthy-string alias. Psalm maintainers also agreed to add the alias, but I am not sure if it was implemented already. and I went ahead and send a patch.

Early feedback

In case you are curious and want to try out the new type, give it a shot at the https://phpstan.org/try sandbox.

💌 Support my open source activities

Honor the work I am doing in my freetime by sponsoring me.

Summary

Adding a new type is a rare task and it required collaboration of a few different people.

Especially getting isSuperType() and isSubType() implementations correct is quite a challenge. Having end-user code examples with actual vs. expected types and error messages helps to work through all the details.

Thanks to everyone involved.

Found a bug? Please help improve this article.


<
Previous Post
Check your sql queries for errors
>
Next Post
Detect slow queries before they hit your production database