chenglou/pretext · error · Error

Expected TypeScript consumer misuse to fail, but it compiled

Error message

Expected TypeScript consumer misuse to fail, but it compiled successfully.

What it means

smokeTypeScript() runs a deliberately invalid consumer program (`layout(prepared, '100', 20)` — passing a string for a number parameter) and asserts tsc rejects it. If tsc exits 0, this throws: it means the package's emitted type declarations are too permissive and no longer catch the misuse. This is a negative type-contract test for the public API.

Source

Thrown at scripts/package-smoke-test.ts:136

      "const prepared = prepare('hello', '16px Inter')",
      "const width = '100'",
      'layout(prepared, width, 20)',
      '',
    ].join('\n'),
  )

  const badCompile = run(
    [path.join(root, 'node_modules', '.bin', tscBinaryName()), '-p', 'tsconfig.json'],
    {
      cwd: projectDir,
      stdout: 'pipe',
      stderr: 'pipe',
      allowFailure: true,
    },
  )

  if (badCompile.exitCode === 0) {
    throw new Error('Expected TypeScript consumer misuse to fail, but it compiled successfully.')
  }

  const combinedOutput = `${badCompile.stdout}${badCompile.stderr}`
  if (
    !combinedOutput.includes("Argument of type 'string' is not assignable to parameter of type 'number'.") &&
    !combinedOutput.includes("Type 'string' is not assignable to type 'number'.")
  ) {
    throw new Error(`Unexpected TypeScript consumer error output:\n${combinedOutput}`)
  }

  console.log('ts ok')
}

async function createProject(dir: string, pkg: Record<string, unknown>): Promise<void> {
  await mkdir(dir, { recursive: true })
  await writeFile(path.join(dir, 'package.json'), JSON.stringify(pkg, null, 2) + '\n')
}

View on GitHub (pinned to ac49b09b7d)

Solutions

  1. Inspect dist/layout.d.ts for the layout() signature and confirm the width parameter is typed exactly `number`.
  2. If the source type was widened, narrow it back in src/layout.ts and rebuild dist/.
  3. Re-run the smoke test to confirm the negative case is rejected again.

Example fix

// before — src/layout.ts widened the type
export function layout(prepared: Prepared, width: string | number, lineHeight: number)

// after
export function layout(prepared: Prepared, width: number, lineHeight: number)
Defensive patterns

Strategy: validation

Validate before calling

// Before shipping, assert the negative type test fails as expected
import { spawnSync } from 'node:child_process'
const r = spawnSync('npx', ['tsc', '-p', 'tsconfig.negative.json'], { encoding: 'utf8' })
if (r.status === 0) throw new Error('Negative type test compiled — layout() width is not typed as number')

Type guard

import type { Prepared } from '@chenglou/pretext'
// Compile-time guard: width MUST be number. If this assignment errors, the contract holds.
type WidthIsNumber = Parameters<typeof import('@chenglou/pretext').layout>[1] extends number ? true : never

Prevention

When it happens

Trigger: The width parameter's type in the published dist/*.d.ts became `string | number`, `any`, or `unknown`, so passing '100' type-checks cleanly. Can also happen if the consumer's tsconfig relaxed strictness — but the smoke test pins strict:true, so the root cause is almost always the package's declarations.

Common situations: A refactor widened a parameter type (e.g. unioning in string for convenience), or a declaration-emission change dropped the precise number type.

Related errors


AI-assisted analysis of chenglou/pretext@ac49b09b7d (2026-08-12). Data as JSON: /api/errors/3e440e513bc36f81. Report an issue: GitHub.