Skip to content
Search

TypeMapper

Back

TypeMapper validates and coerces a database row against a Flow\Types\Type from flow-php/types. Use it for:

  • Strict type validation of incoming row data.
  • Coercion of raw driver values, for example JSONB strings into arrays, date strings into \DateTimeImmutable, or JSONB into typed structures or Flow\Types\Value\Json.
  • Composition with another RowMapper, for example ConstructorMapper or the PostgreSQL Valinor Bridge. The cast result goes to the next mapper.

Internally, TypeMapper::map() calls Type::cast($row). cast() is the lenient counterpart of assert(). It tries to coerce the value into the requested type before the validation. For JSONB-style columns, a JSON string that starts with { or [ is therefore automatically json_decode'd before the inner Type applies.

DSL

type_mapper(Type<TType> $type, ?RowMapper<TNext> $next = null) : TypeMapper<TType, TNext>
  • With $next set to null, map() returns the result of $type->cast().
  • With a $next, the cast result goes to $next->map(), and the return value of $next is the output of the mapper.

Basic Usage - Validating a Row Shape

<?php

use function Flow\PostgreSql\DSL\{pgsql_client, pgsql_connection, type_mapper};
use function Flow\Types\DSL\{type_integer, type_optional, type_string, type_structure};

$client = pgsql_client(pgsql_connection('host=localhost dbname=mydb'));

$mapper = type_mapper(type_structure([
    'id' => type_integer(),
    'name' => type_string(),
    'last_name' => type_optional(type_string()),
]));

$row = $client->fetchInto(
    $mapper,
    'SELECT id, name, last_name FROM users WHERE id = $1',
    [1],
);
// $row === ['id' => 1, 'name' => 'Alice', 'last_name' => null]

Coercing JSONB Columns

Without the native decoding of the driver, PostgreSQL JSON and JSONB columns arrive as strings. TypeMapper handles all three common targets:

JSONB → Nested Structure

<?php

use function Flow\PostgreSql\DSL\type_mapper;
use function Flow\Types\DSL\{type_string, type_structure};

$mapper = type_mapper(type_structure([
    'profile' => type_structure([
        'name' => type_string(),
        'address' => type_structure([
            'city' => type_string(),
            'country' => type_string(),
        ]),
    ]),
]));

// Row from driver: ['profile' => '{"name":"Alice","address":{"city":"Warsaw","country":"PL"}}']
$row = $client->fetchInto($mapper, 'SELECT profile FROM users WHERE id = $1', [1]);
// $row === ['profile' => ['name' => 'Alice', 'address' => ['city' => 'Warsaw', 'country' => 'PL']]]

JSONB → Flow\Types\Value\Json

To pass the JSON value around as a first-class object (with ->toArray(), __toString(), JsonSerializable):

<?php

use function Flow\PostgreSql\DSL\type_mapper;
use function Flow\Types\DSL\{type_json, type_structure};

$mapper = type_mapper(type_structure([
    'metadata' => type_json(),
]));

// Row from driver: ['metadata' => '{"theme":"dark","notifications":true}']
$row = $client->fetchInto($mapper, 'SELECT metadata FROM users WHERE id = $1', [1]);

$json = $row['metadata'];          // \Flow\Types\Value\Json
$json->toArray();                  // ['theme' => 'dark', 'notifications' => true]
(string) $json;                    // '{"theme":"dark","notifications":true}'

JSONB → Plain String

For the raw JSON text, for example to send it to an HTTP response without a new encode:

<?php

use function Flow\PostgreSql\DSL\type_mapper;
use function Flow\Types\DSL\{type_string, type_structure};

$mapper = type_mapper(type_structure([
    'metadata' => type_string(),
]));

// Row from driver: ['metadata' => '{"theme":"dark"}']
$row = $client->fetchInto($mapper, 'SELECT metadata FROM users WHERE id = $1', [1]);
// $row === ['metadata' => '{"theme":"dark"}']

JSONB Array → List of Structures

<?php

use function Flow\PostgreSql\DSL\type_mapper;
use function Flow\Types\DSL\{type_list, type_string, type_structure};

$mapper = type_mapper(type_structure([
    'addresses' => type_list(type_structure([
        'city' => type_string(),
        'country' => type_string(),
    ])),
]));

// Row from driver: ['addresses' => '[{"city":"Warsaw","country":"PL"},{"city":"Berlin","country":"DE"}]']
$row = $client->fetchInto($mapper, 'SELECT addresses FROM users WHERE id = $1', [1]);
// $row === ['addresses' => [
//     ['city' => 'Warsaw', 'country' => 'PL'],
//     ['city' => 'Berlin', 'country' => 'DE'],
// ]]

Coercing Date / Time Columns

PostgreSQL TIMESTAMP / TIMESTAMPTZ already come back as \DateTimeImmutable from ext-pgsql. But text columns with date/time strings, for example ISO formats stored as TEXT, need explicit coercion.

String → \DateTimeImmutable

<?php

use function Flow\PostgreSql\DSL\type_mapper;
use function Flow\Types\DSL\{type_datetime, type_integer, type_structure};

$mapper = type_mapper(type_structure([
    'id' => type_integer(),
    'created_at' => type_datetime(),
]));

// Row from driver: ['id' => 42, 'created_at' => '2026-01-01 14:30:00']
$row = $client->fetchInto($mapper, 'SELECT id, created_at FROM events WHERE id = $1', [42]);

$row['created_at'];                          // \DateTimeImmutable
$row['created_at']->format('Y-m-d H:i:s');   // '2026-01-01 14:30:00'

type_datetime()->cast(...) accepts calendar date and datetime strings, numeric Unix timestamps, \DateTime, \DateTimeImmutable, and \DateInterval. A string must be a real calendar date. A date-only string becomes midnight. A wall-clock expression such as '', 'now' or '+12' is refused, so the same input never produces a different value on a different run. A string without an offset is read in the column zone: UTC for type_datetime(), never date.timezone. Declare type_datetime('Europe/Warsaw') to read local timestamps.

String → Date

For DATE columns (no time component) stored as text:

<?php

use function Flow\PostgreSql\DSL\type_mapper;
use function Flow\Types\DSL\{type_date, type_structure};

$mapper = type_mapper(type_structure([
    'birthday' => type_date(),
]));

// Row from driver: ['birthday' => '1990-04-15']
$row = $client->fetchInto($mapper, 'SELECT birthday FROM users WHERE id = $1', [1]);

$row['birthday'];                  // \DateTimeImmutable normalized to date
$row['birthday']->format('Y-m-d'); // '1990-04-15'

Coercing UUID Columns

<?php

use function Flow\PostgreSql\DSL\type_mapper;
use function Flow\Types\DSL\{type_string, type_structure, type_uuid};

$mapper = type_mapper(type_structure([
    'id' => type_uuid(),
    'name' => type_string(),
]));

// Row from driver: ['id' => '019d9293-793d-7036-918a-f19abffd545c', 'name' => 'Alice']
$row = $client->fetchInto($mapper, 'SELECT id, name FROM users WHERE id = $1', [$id]);

$row['id'];        // \Flow\Types\Value\Uuid
(string) $row['id']; // '019d9293-793d-7036-918a-f19abffd545c'

Optional Fields

Absent and null are two different facts about a structure field. Declare a field as structure_element(..., optional: true) for a column that the driver row can omit fully. Wrap its type in type_optional(...) for a present column that can hold null:

<?php

use function Flow\PostgreSql\DSL\type_mapper;
use function Flow\Types\DSL\{structure_element, type_optional, type_string, type_structure};

$mapper = type_mapper(type_structure([
    'name' => type_string(),
    'nickname' => structure_element('nickname', type_string(), optional: true),
]));

// Row: ['name' => 'Alice', 'nickname' => 'Ali']  → ['name' => 'Alice', 'nickname' => 'Ali']
// Row: ['name' => 'Alice']                       → ['name' => 'Alice']

Chaining Into Another RowMapper ($next)

Pass any RowMapper<T> as the second argument and TypeMapper will forward its cast result to it. This is useful for decode-then-hydrate pipelines: have TypeMapper clean up the raw row (decode JSONB, parse dates) and then hand the clean array to a class-aware mapper.

Chained with ConstructorMapper

<?php

use function Flow\PostgreSql\DSL\{constructor_mapper, type_mapper};
use function Flow\Types\DSL\{type_datetime, type_integer, type_string, type_structure};

readonly class Order
{
    public function __construct(
        public int $id,
        public string $reference,
        public \DateTimeImmutable $createdAt,
    ) {}
}

$mapper = type_mapper(
    type_structure([
        'id' => type_integer(),
        'reference' => type_string(),
        'createdAt' => type_datetime(),
    ]),
    constructor_mapper(Order::class),
);

// Row: ['id' => 42, 'reference' => 'ORD-001', 'createdAt' => '2026-01-01 14:30:00']
$order = $client->fetchInto(
    $mapper,
    'SELECT id, reference, created_at AS "createdAt" FROM orders WHERE id = $1',
    [42],
);
// $order instanceof Order, $order->createdAt instanceof \DateTimeImmutable

Chained with the Valinor Bridge

For complex object graphs (nested DTOs, lists of value objects), combine TypeMapper with the PostgreSQL Valinor Bridge. TypeMapper decodes JSONB and parses dates. Then Valinor builds the strict object tree.

Error Handling

If Type::cast() fails (or the chained $next mapper throws), TypeMapper wraps the error in Flow\PostgreSql\Client\Exception\MappingException. getPrevious() returns the original:

<?php

use Flow\PostgreSql\Client\Exception\MappingException;

try {
    $row = $client->fetchInto($mapper, 'SELECT ... FROM ... WHERE id = $1', [$id]);
} catch (MappingException $e) {
    $cause = $e->getPrevious(); // e.g. Flow\Types\Exception\CastingException
}

Found a typo or an outdated section? Edit this page on GitHub


Contributors

Built in the open.

Join us on GitHub
scroll back to top