Introduction
TypeMapper
TypeMapper validates and coerces a database row against a Flow\Types\Type from flow-php/types. Use it when you need:
- Strict type validation of incoming row data,
- Coercion of raw driver values (e.g. JSONB strings into arrays, date strings into
\DateTimeImmutable, JSONB into typed structures orFlow\Types\Value\Json), - Composition with another
RowMapper(e.g.ConstructorMapperor the PostgreSQL Valinor Bridge) — the cast result is forwarded 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 validating. For JSONB-style columns this means a JSON string starting with { or [ is automatically json_decode'd before the inner Type is applied.
DSL
type_mapper(Type<TType> $type, ?RowMapper<TNext> $next = null) : TypeMapper<TType, TNext>
- When
$nextisnull,map()returns whatever$type->cast()produces. - When
$nextis provided, the cast result is forwarded to$next->map()and$next's return value becomes the mapper's output.
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
PostgreSQL JSON and JSONB columns arrive as strings when you don't use the driver's native decoding. 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
When you want 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
When you want the raw JSON text — for example to forward it to an HTTP response without re-encoding:
<?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 containing date/time strings (e.g. 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 strings, numeric Unix timestamps, \DateTime, \DateTimeImmutable, and \DateInterval.
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
Use type_optional(...) for columns that may be absent in the row OR null:
<?php
use function Flow\PostgreSql\DSL\type_mapper;
use function Flow\Types\DSL\{type_optional, type_string, type_structure};
$mapper = type_mapper(type_structure(
elements: [
'name' => type_string(),
],
optional_elements: [
'nickname' => type_string(),
],
));
// 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
When Type::cast() fails (or the chained $next mapper throws), TypeMapper wraps the error in Flow\PostgreSql\Client\Exception\MappingException and exposes the original via getPrevious():
<?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
}