Feat: foundation for TypeScript migration - #7308
Conversation
- Previous upgrade from v8 -> v10 caused linter to break because of unsupported configuration files
| * var map = new Map([ | ||
| * [ 1, 'one' ], | ||
| * [ 2, 'two' ], | ||
| * [ 3, 'three' ] | ||
| * ]); |
There was a problem hiding this comment.
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>.
| * | ||
| * @returns The callback result. | ||
| */ | ||
| type EachMapCallback<K extends string, V> = (key: K, entry: V) => boolean | void; |
There was a problem hiding this comment.
JSDoc declaration said this callback returns null when in reality it returns entries[key], not null. The type was misleading.
| { | ||
| "compilerOptions": { | ||
| "strict": true, | ||
| "target": "ES2018", |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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).
| 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; | ||
| } |
There was a problem hiding this comment.
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 { ... }
| * 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. | ||
| * | ||
| * |
There was a problem hiding this comment.
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.)
| * `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; |
There was a problem hiding this comment.
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.)
| const record = value as Record<string, unknown>; | ||
|
|
||
| return typeof record.get === 'function' || typeof record.set === 'function'; |
There was a problem hiding this comment.
| 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)
|
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). |
There was a problem hiding this comment.
Man, we really shouldn't track the generated js file and sourcemap looool
There was a problem hiding this comment.
It makes literally no difference to do so.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| */ | ||
| has (key: K): boolean | ||
| { | ||
| return (this.entries.hasOwnProperty(key)); |
There was a problem hiding this comment.
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.
| * @param elements - An array of key-value pairs to populate this Map with. | ||
| * @returns This Map object. | ||
| */ | ||
| setAll (elements?: Array<[K, V]> | null): this |
There was a problem hiding this comment.
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.)
| /** | ||
| * Adds all the elements in the given array to this Map. | ||
| * | ||
| * If the key already exists, the value will be replaced. |
There was a problem hiding this comment.
| * If the key already exists, the value will be replaced. | |
| * If any of the keys already exist, their values will be replaced. |
plurals
| */ | ||
| constructor (elements?: Array<[K, V]> | null) | ||
| { | ||
| this.entries = {} as Record<K, V>; |
There was a problem hiding this comment.
This should be a null-prototype object if or when we are able to use Object.hasOwn
| for (const prop of objectKeys(this.entries)) | ||
| { | ||
| delete this.entries[prop]; | ||
| } |
There was a problem hiding this comment.
Could this just set the object to {}?
That'd be a lot faster if we aren't doing weird stuff.
| * @default {} | ||
| * @since 3.0.0 | ||
| */ | ||
| entries: Record<K, V>; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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).
| * @default 0 | ||
| * @since 3.0.0 | ||
| */ | ||
| _depth: number; |
There was a problem hiding this comment.
we can mark this as private in the TS file
and should
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 generatedtypes/phaser.d.tsworkflow 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
tscas the source of truth for migrated declarations, avoiding somejsdocparser limitationstypes/phaser.d.tsoutput shape for consumersCons / Trade-offs
Technicalities
How distribution artifacts are stitched together
The published
phaser.d.tsremains a single output file, but during the migration period it is assembled fromtsgenoutput and an overlay of declarations emitted bytscfor 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"]Phases of the migration
npm run tsto 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 ofphaser.d.tswill be generated bytscand we should patch JSDoc definitions for the remaining JavaScript modules on top of thattsgencan be removed as declarations will be emitted bytscAgent 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