Skip to content

[RFC / Experiment] Port jsPDF to TypeScript 7 - #4003

Open
MrRio wants to merge 10 commits into
masterfrom
typescript-port
Open

[RFC / Experiment] Port jsPDF to TypeScript 7#4003
MrRio wants to merge 10 commits into
masterfrom
typescript-port

Conversation

@MrRio

@MrRio MrRio commented Jul 15, 2026

Copy link
Copy Markdown
Member

Note

This is an experiment / RFC, not a merge-ready proposal. It explores what a full TypeScript port of jsPDF looks like with the TypeScript 7 native compiler. It may well never be merged — feedback on whether this direction is worth pursuing is the point.

What this does

Ports all of src/ (the 6,179-line core, all 29 plugin modules, all libs) from JavaScript to TypeScript, type-checked by TypeScript 7 (typescript@7.0.2, the native Go compiler), while keeping the public API surface byte-for-byte identical and the file/module structure unchanged.

Because TS 7 no longer exposes a JS compiler API, @rollup/plugin-typescript and karma-typescript can't be used. Instead:

  • Type-checking: npm run typecheck (tsc --noEmit, native binary — the full-repo check runs in well under a second).
  • Transpilation: pure type-stripping with @babel/preset-typescript, wired into all three rollup bundles and into karma (a custom preprocessor serves transpiled .ts under its original .js URL, so browser-native ES-module imports keep resolving).
  • Import specifiers stay .js (import { jsPDF } from "../jspdf.js"), resolved to .ts by tsc natively and by a 10-line rollup plugin — meaning no churn in specs or import sites.

The conversion is deliberately mechanical: function-style constructors, prototype assignment, the IIFE plugin pattern and the // @if MODULE_FORMAT preprocess directives are preserved verbatim. Type annotations were added only where the compiler required them (pragmatic any at dynamic sites); strictNullChecks/noImplicitAny are off for now and can be ratcheted up incrementally later. Two vendored/generated files (WebPDecoder, ttffont) are @ts-nocheck with justification.

Verification

  • API parity: new test/utils/api-parity.js diffs named exports, jsPDF statics, jsPDF.API keys, and instance+prototype keys against a pre-port master build — zero drift (17 exports / 5 statics / 65 API keys / 170 instance keys).
  • Tests: all suites pass at their pre-existing baselines — 623 karma unit tests, 467 node specs, all five deployment suites (amd/esm/globals/typescript/webworker), plus test-typings against the unchanged hand-written types/index.d.ts.
  • Coverage of the converted core/modules is identical to the JS baseline (80.34% / 87.75%), and the node bundle was byte-diffed against a pre-change build to prove the babel pass is a no-op on JS.
  • ~90 new unit tests were added first for the previously-untested libs (Blob, FileSaver, rgbcolor, AtobBtoa, globalObject, console, JPEGEncoder, BMPDecoder, WebPDecoder, omggif) so the port had a safety net.
  • The type-checker surfaced three latent bugs (e.g. PDFSecurity's invalid-permission check reads the literal key "perm" and can never throw); all are preserved as-is for behavior parity and flagged with comments.

Pros of merging

  • Type errors caught at build time across the whole codebase — the port itself surfaced real latent bugs that have been shipping for years.
  • Types can't drift from the implementation. Today types/index.d.ts (1,471 lines) is maintained by hand; long-term, declarations could be generated from source and the hand-written file retired.
  • Much better contributor/editor experience: go-to-definition, refactoring, and inline errors across core and plugins.
  • TS 7 makes this cheap in CI — full type-check is sub-second, and the runtime pipeline is unchanged babel/rollup (dist output is effectively identical; node bundle proven byte-identical on unconverted code).
  • Zero consumer impact: same exports, same bundles, same shipped typings; .js specifiers and file layout unchanged, so git history survives via renames.
  • Strictness can now be ratcheted up file-by-file (strictNullChecks, noImplicitAny) without another big-bang migration.

Cons of merging

  • Every open PR against the JS sources conflicts. There are currently ~17 open PRs, and effectively all of them touch renamed files — each would need rebasing onto the .ts tree by hand. This is the biggest cost, and it only gets cheaper by merging quickly or never.
  • Contributor friction: drive-by contributors now face a type-checker; the erasable-syntax-only rule (no enums/namespaces, stripping must be behavior-neutral) is one more thing to learn even though CONTRIBUTING documents it.
  • The mechanical port is honest but ugly in places: pragmatic any annotations and casts at dynamic sites, plus two @ts-nocheck vendored files. Real type-safety gains need follow-up strictness work; until then the types are mostly scaffolding.
  • Toolchain novelty risk: TS 7 native is new, and the babel-strip + native-typecheck split is less battle-tested than the old tsc-emit path. Anything that parses src/**/*.js by convention (external tooling, jsdoc pipelines) may need adjusting.
  • Blame/archaeology cost: renames are tracked, but line-level git blame across the conversion commit needs -w --follow / .git-blame-ignore-revs hygiene.
  • A hybrid state is possible long-term if momentum stalls: TS sources with hand-written .d.ts still shipped, giving some maintenance duplication until declaration generation is done.

If this direction is rejected

The branch still yielded standalone value that could be cherry-picked: the ~90 new lib unit tests, the API-parity checker, and the latent-bug findings.

What stays JavaScript, and why

A sweep of the remaining .js files (excluding node_modules, dist, docs, generated coverage):

Category Files Verdict
src/ exceptions libs/fflate.js, libs/fast-png.js (1–2-line re-export shims), polyfills.js, license.js Keep JS. The shims are special-cased by exact path in the karma rollup preprocessor (they need real bundling for their npm imports; converting them would make two preprocessor pattern sets overlap on the same files). polyfills.js is a separate entry of bare core-js imports — nothing to type. license.js is a banner template, not a module.
Build/tooling at root rollup.config.js, modules.conf.js, cli.js, deletedocs.js, .eslintrc.js Keep JS. All executed directly by Node/rollup/eslint. Rollup 2 can't load a .ts config, eslint requires a JS config, and typing ~700 lines of build scripts buys nothing for library correctness.
Test specs 50 test/specs/*.spec.js + .spec.mjs Keep JS for now — worthwhile follow-up. This is the one category with real upside: TS specs compiled against types/index.d.ts would turn every test into a typings test and catch declaration drift. But it's ~50 files of churn (doubling the open-PR conflict problem this RFC already has), and both karma and the Node jasmine run would need preprocessor/glob work. Best done as its own incremental effort after a decision on this PR, converting specs opportunistically as they're touched.
Test infrastructure karma configs, test/utils/*, deployment harnesses Keep JS. Loaded directly by karma/jasmine at process start; no strip pipeline exists there and configs are the thing that defines the pipeline.
examples/, fontconverter/ ~40 files Keep JS. User-facing copy-paste examples and a standalone browser tool; converting them would make the documentation worse, not better.

Net: src/ is 100% TypeScript except four deliberate, documented exceptions; everything else that remains .js is tooling, tests, or docs where conversion has negative or deferred value.

MrRio added 10 commits July 15, 2026 09:37
Faithful mechanical conversion of all 6179 lines: function-style
constructors, closure-scoped API, PubSub, @if MODULE_FORMAT directive
blocks and the "0.0.0" version literal all preserved verbatim. Minimal
type annotations (any-typed containers, optional trailing params, casts
on catch variables) only where the TypeScript 7 compiler required them.
Unit coverage of the converted file is identical to the JS baseline.
…x.ts

Adds test/utils/api-parity.js which diffs named exports, jsPDF statics,
jsPDF.API keys and instance own+prototype keys between a reference
surface dump and a candidate cjs bundle. Verified identical to the
pre-port master build.
- remove now-empty .js coverage preprocessor entries
- CONTRIBUTING.md: TS7 toolchain, .js-specifier convention, erasable-syntax
  rule, vendored-file exceptions, api-parity usage
src/polyfills.js imports ./libs/Blob (extensionless) and
./libs/globalObject.js, both now TypeScript. The two polyfill bundle
configs never got the tsResolve/type-stripping plugins, so the build
broke once those libs converted. Extend tsResolve() to handle
extensionless specifiers and share one babelStripTypes() helper across
all bundles.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant