Async and Await in Tyhp

Tier 0 · Story 04Complete

Tyhp provides first-class async and await keywords for asynchronous programming. Async functions return Promise<T> values, and await suspends execution until a promise resolves, returning the resolved value. Under the hood, the implementation uses PHP Fibers for cooperative scheduling, with a fiber-based event loop that supports real non-blocking I/O via stream_select(). The runtime is provided by the tyhp/async Composer package.

Async Function Declaration

An async function is declared by adding the async keyword before function. The declared return type is the resolved value type — the compiler automatically wraps it in Promise<T>. You write the return type as if the function were synchronous, and the compiler handles the Promise wrapping.

<?tyhp

// Async standalone function — declared return type is User,
// actual return type is Promise<User>
async function fetchUser(int $id): User {
    $data = await $http->get("/api/users/{$id}");
    return new User($data);
}

// Async method in a class
class UserService {
    public async function getUser(int $id): User {
        return await $this->repository->find($id);
    }

    // Async void — returns Promise<void>
    public async function deleteUser(int $id): void {
        await $this->repository->delete($id);
    }
}
<?php

// async function fetchUser(int $id): User compiles to:
function fetchUser(int $id): \Tyhp\Promise {
    return \Tyhp\Promise::_async(function () use ($id): User {
        $data = \Tyhp\Promise::_await($http->get("/api/users/{$id}"));
        return new User($data);
    });
}

// async methods compile similarly:
class UserService {
    public function getUser(int $id): \Tyhp\Promise {
        return \Tyhp\Promise::_async(function () use ($id): User {
            return \Tyhp\Promise::_await($this->repository->find($id));
        });
    }

    public function deleteUser(int $id): \Tyhp\Promise {
        return \Tyhp\Promise::_async(function () use ($id): void {
            \Tyhp\Promise::_await($this->repository->delete($id));
        });
    }
}

Await Expression

The await keyword is a unary prefix operator that suspends the current async function until the given Promise<T> resolves, then returns the resolved value of type T. It can only be used inside an async function. If the promise rejects, the exception is thrown at the await point.

<?tyhp

async function processOrder(int $orderId): OrderResult {
    // await unwraps Promise<Order> to Order
    Order $order = await $this->orderRepo->find($orderId);

    // await unwraps Promise<PaymentResult> to PaymentResult
    PaymentResult $payment = await $this->paymentService->charge($order);

    // Multiple awaits can be chained sequentially
    await $this->notificationService->send($order->customerId, 'Order processed');

    return new OrderResult($order, $payment);
}

The Promise<T> Type

The Promise<T> class is provided by the tyhp/async Composer package. It represents an asynchronous computation that eventually resolves to a value of type T. The generic parameter is TReturn extends void|mixed = mixed, so unparameterized Promise defaults to Promise<mixed> (not Promise<void>). Promise<void> is still valid for async functions that return nothing.

Promise Combinators

Promise provides static combinator methods for coordinating multiple async operations.

<?tyhp

// Run multiple operations in parallel with Promise::all()
async function fetchDashboard(int $userId): Dashboard {
    array $results = await Promise::all([
        $this->fetchProfile($userId),
        $this->fetchOrders($userId),
        $this->fetchNotifications($userId)
    ]);

    return new Dashboard($results[0], $results[1], $results[2]);
}

// Race: first to resolve wins
async function fetchWithFallback(string $url): Response {
    return await Promise::race([
        $this->primaryApi->get($url),
        $this->fallbackApi->get($url)
    ]);
}

// Delay for backoff
async function retryWithBackoff(int $attempt): Data {
    await Promise::delay($attempt * 1000);
    return await $this->fetchData();
}

// Batch processing with concurrency control
async function processAll(array<Item> $items): array<Result> {
    return await Promise::batch(
        $items,
        async fn(Item $item): Result => await $this->process($item),
        concurrency: 5
    );
}

// Timeout: throw if not resolved within 5 seconds
async function fetchWithTimeout(string $url): Response {
    return await Promise::timeout($this->http->get($url), 5000);
}

Instance Methods: then, catch, finally

Promise instances support chaining with then(), catch(), and finally() for callback-based composition.

<?tyhp

async function withHandlers(): string {
    string $result = await $this->fetchData()
        ->then(fn(Data $d): string => $d->format())
        ->catch(fn(\Throwable $e): string => 'fallback')
        ->finally(fn(): void => $this->cleanup());

    return $result;
}

Async Closures and Arrow Functions

The async keyword can be applied to closures and arrow functions, creating async lambdas that return Promise<T>.

<?tyhp

// Async closure
$handler = async function(Request $req): Response {
    User $user = await $this->auth->getUser($req);
    return new Response($user);
};

// Async arrow function
$fetch = async fn(int $id): User => await $repo->find($id);

// Async closures as callbacks
$results = await Promise::all(
    \array_map(
        async fn(int $id): User => await $this->fetchUser($id),
        $userIds
    )
);
<?php

// Async closure compiles to closure returning Promise
$handler = function(Request $req): \Tyhp\Promise {
    return \Tyhp\Promise::_async(function () use ($req): Response {
        $user = \Tyhp\Promise::_await($this->auth->getUser($req));
        return new Response($user);
    });
};

// Async arrow function compiles similarly
$fetch = function(int $id) use ($repo): \Tyhp\Promise {
    return \Tyhp\Promise::_async(function () use ($id, $repo): User {
        return \Tyhp\Promise::_await($repo->find($id));
    });
};

Async Overloads

Tyhp supports async function overloads, allowing a function to have both synchronous and asynchronous signatures. The compiler selects the appropriate overload based on the calling context.

<?tyhp

class DataService {
    // Synchronous overload
    public function process(Data $d): Result {
        return $this->doProcess($d);
    }

    // Async overload — declared return is the unwrapped type (Result).
    // Writing `Promise<Result>` here would wrap twice: Promise<Promise<Result>>.
    public async function process(Data $d): Result {
        await $this->validate($d);
        return $this->doProcess($d);
    }
}

Error Handling in Async Functions

Errors in async functions work naturally with try/catch. When an awaited promise rejects, the exception is thrown at the await point, just like a synchronous exception. This makes async error handling feel identical to synchronous error handling.

<?tyhp

async function safeFetch(string $url): ?Data {
    try {
        return await $http->get($url);
    } catch (\Throwable $e) {
        $this->logger->error('Fetch failed', ['error' => $e->getMessage()]);
        return null;
    }
}

// Errors propagate through await chains
async function pipeline(): Result {
    try {
        Data $data = await $this->fetchData();
        Data $validated = await $this->validate($data);
        return await $this->transform($validated);
    } catch (ValidationException $e) {
        return Result::failed($e->getMessage());
    } catch (\Throwable $e) {
        throw new ProcessingException('Pipeline failed', 0, $e);
    }
}

Event Loop and Promise::run()

The event loop is managed by the \Tyhp\EventLoop class from the tyhp/async package. At the lowest async call boundary — the first non-async function that calls an async function — the event loop is auto-started via Promise::run(). The event loop uses stream_select() for non-blocking I/O and supports timers, deferred operations, and fiber scheduling.

<?tyhp

// Entry point: synchronous context calling into async code
function main(): void {
    // Promise::run() starts the event loop and blocks until complete
    Result $result = Promise::run(async function(): Result {
        Data $data = await fetchData();
        return await processData($data);
    });

    echo $result->__toString();
}

Async Iteration

Tyhp supports async iteration via foreach (await $expr as $item). The expression can be an AsyncIterable<T>, a Promise<Iterable<T>>, or a Promise<AsyncIterable<T>>. Each case compiles differently — AsyncIterable uses a while-loop with awaited next()/current() calls, while Promise<Iterable<T>> resolves the promise first then iterates synchronously.

<?tyhp

// Case 1: AsyncIterable<T> — true async iteration
// Each element is awaited individually as it becomes available
async function processMessages(MessageQueue $queue): void {
    foreach (await $queue->messagesAsync() as Message $message) {
        await $this->handle($message);
    }
}

// Case 2: Promise<Iterable<T>> — resolve then iterate
// The entire collection is fetched first, then iterated synchronously
async function processAll(ApiClient $api): void {
    foreach (await $api->fetchAllAsync() as Item $item) {
        $this->process($item);
    }
}

// Key-value async iteration
async function processKeyValues(AsyncIterable $source): void {
    foreach (await $source as string $key => mixed $value) {
        echo "{$key}: {$value}";
    }
}
<?php

// Case 1: AsyncIterable<T> compiles to while-loop with _await:
$__asyncIter_1 = $queue->messagesAsync()->getAsyncIterator();
while (\Tyhp\Promise::_await($__asyncIter_1->next())) {
    $message = \Tyhp\Promise::_await($__asyncIter_1->current());
    \Tyhp\Promise::_await($this->handle($message));
}

// Case 2: Promise<Iterable<T>> compiles to resolve then foreach:
foreach (\Tyhp\Promise::_await($api->fetchAllAsync()) as $item) {
    $this->process($item);
}

// Key-value compiles with currentKey() and currentValue():
$__asyncIter_2 = $source->getAsyncIterator();
while (\Tyhp\Promise::_await($__asyncIter_2->next())) {
    $key = \Tyhp\Promise::_await($__asyncIter_2->currentKey());
    $value = \Tyhp\Promise::_await($__asyncIter_2->currentValue());
    echo "{$key}: {$value}";
}

Cancellation

The tyhp/async package provides CancellationToken and CancellationTokenSource for cooperative cancellation of async operations. CancellationTokenSource implements IsDisposable and can be used with the := operator for automatic disposal.

<?tyhp

async function fetchWithTimeout(string $url): Data {
    // CancellationTokenSource auto-cancels after 5000ms (positional ctor, not `timeout:`)
    $cts := new CancellationTokenSource(5000);
    return await $http->get($url, $cts->getToken());
    // CancellationTokenSource is disposed when scope exits
}

async function fetchWithManualCancel(string $url): ?Data {
    $cts := new CancellationTokenSource();

    try {
        return await $http->get($url, $cts->getToken());
    } catch (\Throwable $e) {
        $cts->cancel(); // manually cancel on error
        return null;
    }
}

Awaiting in Loops

The await keyword works naturally inside loops. Each iteration suspends and resumes independently.

<?tyhp

async function fetchSequentially(array<string> $urls): array<Response> {
    array<Response> $results = [];
    foreach ($urls as string $url) {
        $results[] = await $http->get($url);
    }
    return $results;
}

// For parallel execution, use Promise::all() instead:
async function fetchInParallel(array<string> $urls): array<Response> {
    return await Promise::all(
        \array_map(
            async fn(string $url): Response => await $http->get($url),
            $urls
        )
    );
}

Best Practices

Tip

Use await to unwrap promises — it provides clean, sequential-looking code that is easier to read and debug than callback chains.

Tip

Use Promise::all() for independent operations that can run concurrently. This significantly improves performance compared to sequential awaits.

Tip

Use Promise::batch() with a concurrency limit when processing large collections to avoid overwhelming external services or running out of memory.

Tip

Always handle errors in async functions with try/catch around await expressions. Unhandled rejections in async functions propagate silently.

Tip

Use CancellationToken with the := operator for timeout and cancellation scenarios. The disposal mechanism ensures tokens are cleaned up when the scope exits.

Common Mistakes

Danger

Using await outside of an async function. The compiler reports error 4028: "await can only be used inside an async function." Wrap the calling code in an async function or use Promise::run() at the entry point.

Danger

Forgetting to await a promise (fire-and-forget). An unawaited promise executes but errors are silently lost. Always await promises or explicitly handle them with then()/catch().

Danger

Using blocking I/O inside async functions. Blocking calls (like synchronous file_get_contents()) block the entire event loop, preventing other fibers from running. Use the event loop's non-blocking I/O facilities instead.

Danger

Mixing raw Fiber usage with async/await. The tyhp/async event loop manages fibers internally — creating and managing fibers directly can interfere with the event loop's scheduling.

Danger

Iterating an AsyncIterable<T> without await in the foreach. The compiler reports an error — async iterables must use foreach (await $expr as $item) inside an async function.

Compiler Errors

<?tyhp

// ERROR 4028: await can only be used inside an async function
function notAsync(): int {
    // return await somePromise();  // Compiler error!
    return 0;
}

// The declared return type IS the unwrapped type — Tyhp wraps it in Promise<T>
async function good(): int {
    return 42; // OK: returns Promise<int>, declared as int
}

// ERROR: Cannot iterate AsyncIterable<T> synchronously
function badIteration(AsyncIterable<Message> $msgs): void {
    // foreach ($msgs as $m) {}  // Compiler error!
}
// FIX: Use await in foreach inside async function
async function goodIteration(AsyncIterable<Message> $msgs): void {
    foreach (await $msgs as Message $m) {
        await $this->handle($m);
    }
}

Note

The tyhp/async Composer package is automatically added as a dependency to your output project when the compiler detects usage of async/await keywords or disposable features. You do not need to manually install it.