Skip to content

Feat: foundation for TypeScript migration - #7308

Draft
niklas-e wants to merge 17 commits into
phaserjs:masterfrom
niklas-e:feat/typescript-migration-foundation
Draft

Feat: foundation for TypeScript migration#7308
niklas-e wants to merge 17 commits into
phaserjs:masterfrom
niklas-e:feat/typescript-migration-foundation

Conversation

@niklas-e

Copy link
Copy Markdown

Disclaimer: this contains the eslint config fix commit from #7304, to enable proper lint configuration for TS

Introduction

This PR starts an incremental JavaScript to TypeScript migration path for Phaser. I acknowledge this will be an epic journey if we go on this path.

The motivation is related to #7298: the current declaration pipeline still depends heavily on jsdoc, which has known parser limitations and an uncertain maintenance future. Rather than proposing an all-at-once rewrite, this branch shows that Phaser can migrate individual modules to native TypeScript while keeping the existing source tree, build output, and generated types/phaser.d.ts workflow intact.

The migrated modules are intentionally small but varied, covering math helpers, a class, mixins, a struct, geometry, and a game object helper. They are now discovered automatically from migrated TypeScript source via JSDoc namespace tags, and the type generation step overlays authoritative TypeScript declarations for those migrated symbols into the existing Phaser declaration file.

Pros

  • Allows gradual migration from JS to TS without requiring the whole codebase to move at once
  • Keeps existing JavaScript modules working alongside migrated TypeScript modules
  • Uses tsc as the source of truth for migrated declarations, avoiding some jsdoc parser limitations
  • Gives migrated code real compiler checking instead of relying only on JSDoc annotations
  • Preserves the existing public types/phaser.d.ts output shape for consumers
  • Adds validation around migrated symbols so missing or incorrect generated declarations are caught
  • Provides a practical foundation for continuing module-by-module migration
  • When whole migration is completed, .d.ts maps can be published which enables navigation to source definition

Cons / Trade-offs

  • The type generation pipeline becomes more complex while Phaser is in a hybrid JS/TS state
  • Contributors need to understand both existing JSDoc-based type generation and the new TypeScript overlay path

Technicalities

How distribution artifacts are stitched together

The published phaser.d.ts remains a single output file, but during the migration period it is assembled from tsgen output and an overlay of declarations emitted by tsc for migrated modules.

flowchart TD
    jsFiles["JSDoc (.js files)"] --> jsdoc["jsdoc parser"] --> dtsDom["dts-dom tree"]
    tsFiles["TypeScript (.ts files)"] --> discovery["auto-discovery (JSDoc tags)"] --> tsc["tsc --emitDeclarationOnly"]

    dtsDom --> overlay["MigratedOverlay<br/>(synthetic stubs + overlay)"]
    tsc --> overlay

    overlay --> publish["publish.ts<br/>(orchestrator)"]
    publish --> phaser["phaser.d.ts"]
Loading

Phases of the migration

  1. Start with the tsgen + overlay strategy shown above
  2. Continue using npm run ts to update and validate the type declarations during the migration period. Once migrated modules reach 50% of source modules, it will also start warning that it may be time to consider a TS-first declaration pipeline. No concrete plan yet, but at that point most of phaser.d.ts will be generated by tsc and we should patch JSDoc definitions for the remaining JavaScript modules on top of that
  3. Once the codebase is fully migrated to TypeScript, tsgen can be removed as declarations will be emitted by tsc

Agent skill for migrating modules

I wrote and tested an agent skill to help with the migration. This should be a great aid for contributors. You can find it here: https://github.com/niklas-e/phaser-typescript

TODO

  • Remove dist artifacts before merging (included for easier testing within actual projects)
  • Setup an umbrella issue and/or GH project to track the progress
  • Ensure docs.phaser.io works as expected - did not find the sources for it so couldn't see how it is set up
  • Write a contribution guide

Comment thread src/structs/Map.ts
Comment on lines +25 to +29
* var map = new Map([
* [ 1, 'one' ],
* [ 2, 'two' ],
* [ 3, 'three' ]
* ]);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSDoc version allowed numbers for keys, but it is confusing while using an object for storage internally. In JavaScript this will be true obj[1] === obj["1"], thus causing potential runtime bugs. Therefore I set the signature to Map<K extends string = string, V = unknown>.

Comment thread src/structs/Map.ts
*
* @returns The callback result.
*/
type EachMapCallback<K extends string, V> = (key: K, entry: V) => boolean | void;

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSDoc declaration said this callback returns null when in reality it returns entries[key], not null. The type was misleading.

Comment thread tsconfig.json
{
"compilerOptions": {
"strict": true,
"target": "ES2018",

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would be a comfortable target without dropping too much compatibility? I understand there's probably varying views, but my point of view is that as a framework Phaser could ship builds with quite modern versions and people who want to support really old stuff can use existing tools in the ecosystem to convert to their preferred ES version. --- Also good to note we should definitely set the target to at least ES2015 to not have the distributables include bunch of utility code for e.g. classes

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ES2022 seems to be fairly safe to target (even Chromebooks support it), though if you want to be extra safe ES2021 or ES2020 should be good.

ES2023 is where you'll start sacrificing some meaningful compatibility with actual in-use devices I think; we started getting reports from Chromebook users being unable to play when we tried to move from ES2022 to ES2023 (though I don't know if it was only a small subset of Chromebooks or the majority that didn't support it since we quickly dropped back to ES2022 with polyfills for the ES2023 features we wanted to use).

Comment thread src/utils/Mixin.ts
Comment on lines +107 to +119
export function composeMixins<const TMixins extends readonly Mixin<object>[]> (
...mixins: TMixins
): <TBase extends Constructor>(
Base: TBase
) => TBase & Constructor<InstanceType<TBase> & AddedByAll<TMixins>>
{
// The reduce chain is type-safe at call sites via the overload signature;
// the implementation needs a single cast to bridge the generic gap.
return ((Base: Constructor) =>
mixins.reduce((Current, mixin) => mixin(Current), Base)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) as any;
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's multiple ways to implement mixins, but this composable implementation keeps the footprint same size as before.

For comparison the worst case scenario with TS handbook mixins in current codebase would be:

// Note: creates many anonymous classes
const SpriteBase =
  Alpha(
  BlendMode(
  Depth(
  Flip(
  GetBounds(
  Lighting(
  Mask(
  Origin(
  RenderNodes(
  ScrollFactor(
  Size(
  TextureCrop(
  Tint(
  Transform(
  Visible(
    GameObject
  )))))))))))))));

export class Sprite extends SpriteBase { ... }

But with the composable approach it would be:

// Note: creates only single class
const SpriteBase = composeMixins(
  Alpha,
  BlendMode,
  Depth,
  Flip,
  GetBounds,
  Lighting,
  Mask,
  Origin,
  RenderNodes,
  ScrollFactor,
  Size,
  TextureCrop,
  Tint,
  Transform,
  Visible)(GameObject);

export class Sprite extends SpriteBase { ... }

Comment on lines +4 to +8
* The built-in `Object.keys()` always returns `string[]`, which loses
* generic key information. This helper returns `K[]` instead, making it
* safe to use the result to index back into the same record without casts.
*
*

@Bertie690 Bertie690 Jun 6, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will mention that such a type-preserving Object.keys method should probably have a disclaimer about it being technically unsound due to structural typing allowing excess properties.
It's fine if the object in question is effectively nominal (i.e. we know it won't contain any extra keys), but should MUST be avoided for anything where structural typing could possibly become involved.

(Also, we should absolutely not export this thing.)

Comment thread src/utils/Mixin.ts
* `new (scene: Scene, type: string) => T` to the generic constraint.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Constructor<T = object> = new (...args: any[]) => T;

@Bertie690 Bertie690 Jun 6, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this ever gets needed somewhere else later down the line, we could potentially improve this further:
(Improvement copied straight from type-fest)

type Constructor<T, Arguments extends unknown[] = any[]> = new (...arguments_: Arguments) => T

(As an aside, it may be wise to look into adding type-fest or a similar library as a dev dependency - they've got some very useful type utilities with much better edge-case support than your average hand-rolled versions.)

Comment thread src/utils/Mixin.ts
Comment on lines +28 to +30
const record = value as Record<string, unknown>;

return typeof record.get === 'function' || typeof record.set === 'function';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const record = value as Record<string, unknown>;
return typeof record.get === 'function' || typeof record.set === 'function';
return typeof (value as Record<string, unknown>).get === 'function' || typeof (value as Record<string, unknown>).set === 'function';

IMO, we should generally avoid creating temp variables for the compiler's sake where possible (type assertions are made for these kinds of things)

@photonstorm

Copy link
Copy Markdown
Collaborator

Just to mention that we are reading this, and are interested in doing it, but we have no capacity for such an undertaking for quite a while. At least until the end of August. Even if the community (thank you!) and let's face it, Claude, does the majority of the work, this change would have multiple tooling ramifications for us, well beyond simply updating the library and pushing to npm. Even without changing the API surface, this change would be profound.

Also, if we're going to go down this path, we will almost certainly do it as part of a v5.0 release, to give us the chance to break the API in significant ways (i.e., to avoid the use of mixins).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Man, we really shouldn't track the generated js file and sourcemap looool

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It makes literally no difference to do so.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It increases the number of touched files per changeset and makes cloning the repo take more time.

Not necessarily a massive issue per se, but still far from nothing.

It's effectively the same argument for including autogenerated IDE files inside gitignore, albeit on a much smaller scale.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's 4 files with a combined size of less than 49KB, so about as close to nothing as you could get in the grand scheme of things.

@Bertie690 Bertie690 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Random things I noticed

Comment thread src/structs/Map.ts
*/
has (key: K): boolean
{
return (this.entries.hasOwnProperty(key));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we upgrade further, we could (and arguably SHOULD) use Object.hasOwn; this breaks if you happen to add a string hasOwnProperty to the map.

Comment thread src/structs/Map.ts
* @param elements - An array of key-value pairs to populate this Map with.
* @returns This Map object.
*/
setAll (elements?: Array<[K, V]> | null): this

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From a pure correctness standpoint, I'd rather not have functions be able to take arguments that do literally nothing for them.
Worst comes to worst, some nullish coaclescing works out equivalently while preventing dumb no-ops like setAll(null).

(This principle can likely be extended to other functions as well.)

Comment thread src/structs/Map.ts
/**
* Adds all the elements in the given array to this Map.
*
* If the key already exists, the value will be replaced.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* If the key already exists, the value will be replaced.
* If any of the keys already exist, their values will be replaced.

plurals

Comment thread src/structs/Map.ts
*/
constructor (elements?: Array<[K, V]> | null)
{
this.entries = {} as Record<K, V>;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be a null-prototype object if or when we are able to use Object.hasOwn

Comment thread src/structs/Map.ts
Comment on lines +189 to +192
for (const prop of objectKeys(this.entries))
{
delete this.entries[prop];
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this just set the object to {}?
That'd be a lot faster if we aren't doing weird stuff.

Comment thread src/structs/Map.ts
* @default {}
* @since 3.0.0
*/
entries: Record<K, V>;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we even need to expose this? If you need direct access to the map's contents, that's almost certainly a sign that you don't want to be using a built-in utility struct.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to the v3-to-v4 migration guide, Phaser.Struct.Map and .Set have been replaced with JS's built-in versions, so I don't think this file even needs to be ported to TS (and since it seems like the Phaser devs want to make a TS port a major version bump, it should definitely just be fully dropped if it's already unused in v4).

image

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this.

* @default 0
* @since 3.0.0
*/
_depth: number;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can mark this as private in the TS file

and should

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.

4 participants