DEV Community

Cover image for PHP 8.6 Adds foo(?, 2) Partial Application. Here's How to Write It in TypeScript
Gabriel Anhaia
Gabriel Anhaia

Posted on AI-assisted

PHP 8.6 Adds foo(?, 2) Partial Application. Here's How to Write It in TypeScript


The PHP 8.5 release page shows off the new pipe operator with this chain:

$slug = $title
    |> trim(...)
    |> (fn($str) => str_replace(' ', '-', $str))
    |> (fn($str) => str_replace('.', '', $str))
    |> strtolower(...);
Enter fullscreen mode Exit fullscreen mode

Two of those four steps are arrow functions whose only job is to move $str into the third slot of str_replace(). In PHP 8.6 each one shrinks to str_replace(' ', '-', ?). The Partial Function Application v2 RFC by Larry Garfield and Arnaud Le Blanc closed its vote on December 5, 2025, adopted 33-0-0, targeting PHP 8.6. That release is already in release candidates, so its feature set is frozen: the php.net archive shows RC3 out on October 8.

If you write PHP by day and TypeScript the rest of the time, the next question is what the same move looks like on the other side. There are three answers, and each one gives up something PHP's version keeps.

What ? and ... do in PHP 8.6

A call with at least one placeholder doesn't run the function. It returns a Closure. ? stands for exactly one argument. ... stands for zero or more and can appear once, at the end. Positional values and ? come first, named arguments after. The closure copies the real function's parameter types, names and return type.

function fee(int $cents, float $rate, string $currency): string
{
    return sprintf('%.2f %s', $cents * $rate / 100, $currency);
}

$eur    = fee(?, 0.029, 'EUR');         // fn(int $cents)
$at1999 = fee(1999, ?, 'EUR');          // fn(float $rate)
$tail   = fee(1999, ...);               // fn(float, string)
$thunk  = fee(1999, 0.029, 'EUR', ...); // fn(): string

// Named placeholders: the closure takes (currency, cents)
$byName = fee(currency: ?, cents: ?, rate: 0.029);

echo $eur(1999); // 0.58 EUR
Enter fullscreen mode Exit fullscreen mode

Two details matter for the TypeScript side. First, bound arguments are evaluated when the partial is created, not when it's called. The RFC contrasts the two forms with an example of this shape:

// currentRate() runs once, right here
$partial = fee(?, currentRate(), 'EUR');

// currentRate() runs on every call
$arrow = fn(int $c) => fee($c, currentRate(), 'EUR');
Enter fullscreen mode Exit fullscreen mode

Second, a follow-up RFC on optional parameters, accepted 25-0 with one abstention in May 2026, makes every parameter created by ? required, even when the original had a default. Parameters pulled in by ... keep their optionality.

The arrow function is the TypeScript default

TypeScript has no placeholder syntax, so the everyday answer is the thing PHP 8.6 replaces: a closure that forwards.

function fee(cents: number, rate: number, currency: string) {
  return `${((cents * rate) / 100).toFixed(2)} ${currency}`;
}

const eurFee = (cents: number) => fee(cents, 0.029, "EUR");
const rateFor1999 = (rate: number) => fee(1999, rate, "EUR");
const thunkFee = () => fee(1999, 0.029, "EUR");

console.log(eurFee(1999)); // 0.58 EUR
Enter fullscreen mode Exit fullscreen mode

It covers every PHP form: a hole at the front, a hole in the middle, a zero-argument thunk. What it costs is repetition. You restate each open parameter's type by hand, and if fee changes its signature, the wrapper's annotation is one more place to update.

It also evaluates lazily, like PHP's arrow form and unlike fee(?, ...). There's one more difference a PHP developer will trip on: PHP arrow functions capture outer variables by value when they're created, while a JavaScript closure reads the variable itself, so it sees every later reassignment.

bind() only fills the front

The built-in alternative is Function.prototype.bind. Since TypeScript 3.2, the strictBindCallApply option (part of strict) types it properly instead of returning any.

const forCents = fee.bind(null, 1999);
// takes (rate, currency), returns string

console.log(forCents(0.029, "EUR")); // 0.58 EUR
Enter fullscreen mode Exit fullscreen mode

The typing comes from CallableFunction in the lib.es5.d.ts declarations, where bind is declared over a function with parameters [...A, ...B]: A is what you pass, B is what's left. That gets you PHP's fee(1999, ...) with correct types.

It stops there. bind only fills arguments from the left, so fee(?, 0.029, 'EUR') has no bind equivalent. You always pass a this value, even for a plain function. And the 3.2 release notes named two limits at launch: generic functions lost their type parameters, and for an overloaded function only the last overload is modeled.

A typed partial() with a placeholder

You can get the middle-of-the-list hole back with a placeholder value and some type-level work. Lodash's _.partial accepts _ as a placeholder; what follows is a small typed version built on the variadic tuple types TypeScript 4.0 introduced and the conditional-type infer keyword that has been around since TypeScript 2.8.

Save this as partial.ts:

export {};

const _ = Symbol("placeholder");
type _ = typeof _;

type Arr = readonly unknown[];

// Each argument is either a real value or the placeholder.
type Slots<P extends Arr> = { [K in keyof P]: P[K] | _ };

// Walk params and args together; keep params that got a _.
type Holes<P extends Arr, A extends Arr> =
  A extends readonly [infer AH, ...infer AT]
    ? P extends readonly [infer PH, ...infer PT]
      ? AH extends _
        ? [PH, ...Holes<PT, AT>]
        : Holes<PT, AT>
      : []
    : P;

function partial<P extends Arr, R, A extends Slots<P>>(
  f: (...args: P) => R,
  ...args: A
): (...rest: Holes<P, A>) => R;
function partial(f: (...a: any[]) => any, ...args: any[]) {
  return (...rest: any[]) => {
    let i = 0;
    const full = args.map((a) => (a === _ ? rest[i++] : a));
    return f(...full, ...rest.slice(i));
  };
}

function fee(cents: number, rate: number, currency: string) {
  return `${((cents * rate) / 100).toFixed(2)} ${currency}`;
}

const eur = partial(fee, _, 0.029, "EUR");   // (cents) => string
const at1999 = partial(fee, 1999, _, "EUR"); // (rate) => string
const twoHoles = partial(fee, _, 0.029, _);  // (cents, currency)

console.log(eur(1999));              // 0.58 EUR
console.log(at1999(0.035));          // 0.70 EUR
console.log(twoHoles(10000, "USD")); // 2.90 USD

// partial(fee, _, "0.029", "EUR"); // type error: not a number
// partial(fee, _, 0.029);          // type error: 3 slots needed
Enter fullscreen mode Exit fullscreen mode

Slots<P> is a mapped type over the parameter tuple, so every position accepts its real type or _. Because A must fit all of Slots<P>, leaving a slot out is a compile error, which matches PHP throwing ArgumentCountError for a ?-only call that doesn't cover the required parameters. Holes<P, A> walks both tuples one element at a time with infer and keeps the parameter types that sat under a _. That tuple becomes the rest parameter of the returned function.

The implementation signature uses any on purpose. The public overload carries the types; the body only swaps placeholders for incoming arguments in order and appends anything extra.

PHP's fee(1999, ...) form doesn't need a placeholder at all. The partialCall example from the TypeScript 4.0 notes already covers it, and with every argument supplied it gives you a thunk:

function partialHead<H extends Arr, T extends Arr, R>(
  f: (...args: [...H, ...T]) => R,
  ...head: H
): (...tail: T) => R {
  return (...tail: T) => f(...head, ...tail);
}

const later = partialHead(fee, 1999, 0.029, "EUR"); // () => string
console.log(later()); // 0.58 EUR
Enter fullscreen mode Exit fullscreen mode

Both helpers evaluate bound arguments at creation time, like PHP. Append this to the file to see the difference from a closure:

let rate = 0.029;
const bound = partial(fee, _, rate, "EUR");
const live = (cents: number) => fee(cents, rate, "EUR");
rate = 0.05;

console.log(bound(10000)); // 2.90 EUR: rate read at creation
console.log(live(10000));  // 5.00 EUR: rate read at call time
Enter fullscreen mode Exit fullscreen mode

Run it with tsx:

npx tsx partial.ts
npx -p typescript tsc --noEmit --strict --target es2022 partial.ts
Enter fullscreen mode Exit fullscreen mode

The second command matters. tsx strips types and runs the file, and its docs say plainly that it does not type-check your code. The two commented-out bad calls only fail under tsc.

Where PHP's version still wins

Named arguments. fee(currency: ?, cents: ?, rate: 0.029) works on the original function. TypeScript has no named arguments, so the closest you get is an options object and a helper that fixes some keys:

type FeeArgs = { cents: number; rate: number; currency: string };

const feeOf = ({ cents, rate, currency }: FeeArgs) =>
  fee(cents, rate, currency);

function fix<O extends object, K extends keyof O, R>(
  f: (opts: O) => R,
  fixed: Pick<O, K>,
): (rest: Omit<O, K>) => R {
  return (rest) => f({ ...fixed, ...rest } as O);
}

const usd = fix(feeOf, { currency: "USD" });
console.log(usd({ cents: 10000, rate: 0.029 })); // 2.90 USD
Enter fullscreen mode Exit fullscreen mode

It works, but you had to change the function's shape to get there, and the as O cast is there because the compiler can't prove that Pick<O, K> plus Omit<O, K> adds back up to O.

Signature fidelity. PHP's closure copies names, types and by-reference parameters from the real function (defaults too, for parameters pulled in by ...), and named placeholders can reorder them. The Holes tuple keeps types but not parameter names, so editor hints get vaguer than the original.

Overloads and this. When TypeScript infers from an overloaded function, the handbook says inferences come from the last signature, and that applies to partial() as much as to bind. The helper also drops this, while the RFC lets you partially apply methods, static methods and invokable objects directly.

Syntax. In PHP, ? is part of the language, and the RFC says the compiler turns some pipe forms such as foo(?) into direct calls. In TypeScript, every partial() is a runtime closure plus an array walk.

TypeScript's advantage is the compile-time check: the two commented-out calls at the bottom of partial.ts show up red in your editor before anything runs.

For most code, the arrow function is still the right TypeScript answer. It's one line, and there's nothing to explain in review. Reach for partial() when you're building callbacks in a loop or feeding a pipeline and the hole sits in the middle of the argument list.


If this was useful

This post is one feature mapped across two languages, with the places where the mapping leaks. That's what PHP to TypeScript does for a whole PHP 8+ habit set: sync-to-async, generics and discriminated unions. It's part of The TypeScript Library:

If you came here from PHP, PHP to TypeScript stands in for the first book, and The TypeScript Type System is where Slots and Holes stop looking like magic. Shipping TypeScript at work already? Add TypeScript in Production. The whole set is at xgabriel.com/the-typescript-library.

All five books ship in ebook, paperback, and hardcover.

The TypeScript Library — the 5-book collection

Top comments (1)

Collapse
 
pricilaoliveirarocha profile image
Pri •

Is PHP not dying ?