Skip to content

[Regression]: page.evaluate() arg of a branded primitive type (string & { brand }) no longer type-checks since 1.62 #42000

Description

@kirillgroshkov

Investigated and reported by AI, reviewed by me.


Version

1.62.0 (regressed from 1.61.1)

Steps to reproduce

Passing a "branded"/nominal primitive — an intersection of a primitive and an object type, e.g. type IsoDate = string & { [__brand]: 'IsoDate' } — as the arg of page.evaluate() no longer type-checks in 1.62.0.

repro.ts:

import { test } from '@playwright/test'

declare const __brand: unique symbol
type Branded<T, B> = T & { [__brand]: B }
type IsoDate = Branded<string, 'IsoDate'>

declare function takesIsoDate(date: IsoDate): void

test('branded arg', async ({ page }) => {
  const date = '2026-01-15' as IsoDate

  // 1) explicitly annotated callback param
  await page.evaluate((d: IsoDate) => void d, date)

  // 2) inferred param, forwarded to something that wants the branded type
  await page.evaluate(d => takesIsoDate(d), date)

  // 3) branded value nested in an object arg
  await page.evaluate(({ d }) => takesIsoDate(d), { d: date })
})

tsconfig.json:

{
  "compilerOptions": {
    "strict": true,
    "noEmit": true,
    "module": "preserve",
    "target": "es2022",
    "moduleResolution": "bundler",
    "skipLibCheck": true
  },
  "include": ["repro.ts"]
}
npm i --ignore-scripts @playwright/test@1.61.1 typescript@5.9.3 && npx tsc -p tsconfig.json   # 0 errors
npm i --ignore-scripts @playwright/test@1.62.0                && npx tsc -p tsconfig.json   # 3 errors

Expected behavior

tsc reports no errors, as with 1.61.1: Unboxed<IsoDate> should stay IsoDate, since a branded primitive is serializable and contains no handles.

Actual behavior

All three call sites fail. Unboxed<IsoDate> resolves to an object type whose String.prototype methods have been rewritten to return promises, and which is therefore no longer assignable to string:

repro.ts(13,23): error TS2769: No overload matches this call.
  Overload 1 of 2, '(pageFunction: PageFunction<IsoDate, undefined>, arg: IsoDate, ...): Promise<undefined>', gave the following error.
    Argument of type '(d: IsoDate) => undefined' is not assignable to parameter of type 'PageFunction<IsoDate, undefined>'.
      Type '(d: IsoDate) => undefined' is not assignable to type '(arg: { readonly [x: number]: string; toString: () => Promise<string>; charAt: (pos: number) => Promise<string>; ... 46 more ...; [__brand]: "IsoDate"; }) => Promise<...> | undefined'.
        Types of parameters 'd' and 'arg' are incompatible.
          Type '{ readonly [x: number]: string; toString: () => Promise<string>; ... 46 more ...; [__brand]: "IsoDate"; }' is not assignable to type 'IsoDate'.
            ... is not assignable to type 'string'.

repro.ts(16,41): error TS2345: Argument of type '{ readonly [x: number]: string; toString: () => Promise<string>; ... }' is not assignable to parameter of type 'IsoDate'.
repro.ts(19,47): error TS2345: Argument of type '{ readonly [x: number]: string; toString: () => Promise<string>; ... }' is not assignable to parameter of type 'IsoDate'.

Additional context

The cause looks like the new callback support in evaluate (types/structs.d.ts).

1.61.1:

export type NoHandles<Arg> = Arg extends JSHandle ? never : (Arg extends object ? { [Key in keyof Arg]: NoHandles<Arg[Key]> } : Arg);
export type Unboxed<Arg> =
  Arg extends ElementHandle<infer T> ? T :
  Arg extends JSHandle<infer T> ? T :
  Arg extends NoHandles<Arg> ? Arg :
  ...

1.62.0 (added Arg extends Function ? never in NoHandles, and a function branch in Unboxed):

export type NoHandles<Arg> = Arg extends JSHandle ? never : (Arg extends Function ? never : (Arg extends object ? { [Key in keyof Arg]: NoHandles<Arg[Key]> } : Arg));
export type Unboxed<Arg> =
  Arg extends ElementHandle<infer T> ? T :
  Arg extends JSHandle<infer T> ? T :
  Arg extends (...args: infer T) => infer R ? (...args: T) => CallbackResult<R> :
  Arg extends NoHandles<Arg> ? Arg :
  ...

For string & { [__brand]: 'IsoDate' }, Arg extends object is true (the intersection contains an object type), so NoHandles maps over keyof Arg, which includes every String.prototype method. In 1.61 those methods took the Arg extends object branch and mapped to {}-like types, so Arg extends NoHandles<Arg> still held and Unboxed<Arg> returned Arg unchanged. In 1.62 the new Arg extends Function ? never branch maps each method to never, so Arg extends NoHandles<Arg> is now false, Unboxed falls through to the object-mapping branch, and the new function branch rewrites each method to (...args) => Promise<...>.

A guard for primitives before the object branches would fix it, e.g.:

export type Unboxed<Arg> =
  Arg extends ElementHandle<infer T> ? T :
  Arg extends JSHandle<infer T> ? T :
  Arg extends string | number | boolean | bigint | symbol | null | undefined ? Arg :   // <-- branded primitives
  Arg extends (...args: infer T) => infer R ? (...args: T) => CallbackResult<R> :
  ...

Branded primitives are a common way to model nominal types (IsoDate, UnixTimestamp, UserId, …), so this affects any suite that passes such values into the page. Current workaround is to widen to the base primitive at the boundary and re-brand inside the callback, which loses type safety on the argument.

Environment

- Operating System: macOS 26.5.2
- Playwright Version: 1.62.0 (1.61.1 is fine)
- TypeScript Version: 5.9.3 (also reproduces with 6.0.3)
- Node.js Version: v24.18.0
- Browser: n/a (types only)

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions