-
Notifications
You must be signed in to change notification settings - Fork 1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[RFC] TS migration patterns #1379
Conversation
CI is automatically triggering the following test suites:
|
// Properties from `frontend-js-spa-web/init` | ||
declare global { | ||
export interface ILiferay { | ||
SPA?: { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Moved here, this could simply be SPA?: typeof App
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I think it would be better because eventually we can modify the App and forget to update the types here.
@@ -16,6 +16,7 @@ import SubscriberMap from './SubscriberMap'; | |||
import deepFreeze from './deepFreeze'; | |||
|
|||
import type {Immutable} from './types'; | |||
import * as Types from 'frontend-js-web/src/main/resources/META-INF/resources/liferay/types'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not loving this 😂
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
😍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It would be great to add global types in a custom folder to be added via typeRoots
to avoid importing types explicitly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I actually prefer having to import types explicitly instead of them being globally available everywhere. I think this would also make sense for java devs to see types imported as well.
Plus, you can explicitly import types with ts import type {Liferay} from '/some-path/here';
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm to some extent I agree with that but it's a little weird because you wouldn't be using Liferay
directly because they are declared globally, that's why I find it weirder because normally this behavior is so that it's visible to all files because we are adding to window
, opposed to defining the specific types of a module. For example, it might make sense if it had a syntax like:
import type '/path/types';
Maybe if the types for Liferay weren't declare global
but that wouldn't work. Either way it would be the same thing as the global types for Window.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe if the types for Liferay weren't
declare global
Yeah I think in an ideal world we wouldn't use Liferay
as a global object and slowly more our use of it to be importable. But that is probably a long way off
❌ ci:test:sf - 0 out of 1 jobs passed in 2 minutesClick here for more details.Base Branch:Branch Name: master Sender Branch:Branch Name: types 1 Failed Jobs:For more details click here.
|
Jenkins Build:test-portal-source-format#575 Jenkins Report:jenkins-report.html Jenkins Suite:sf Pull Request:liferay-frontend#1379 Testray Routine:EE Pull Request Testray Importer:publish-testray-report#1817 |
*/ | ||
export default function addParams(params, baseUrl) { | ||
export default function addParams(params: string | object, baseUrl: string) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We most certainly don't want object
here but something like { [key:string]: string }
... does that have a universal name?
Also... see comments about runtime type guards in the commits... keep'em or drop'em?
Additional future warning... typeof params !== 'object'
might not be telling you what you thing is telling you... maybe we should lint about this or something, idk!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
{ [key:string]: string }
... does that have a universal name ?
Nope, but we can create one if it is very common...
I usually create types for this kind of option arguments. So something like this I think it's possible:
addParamsOptions {
knownOption1: string;
knownOption2: boolean;
[key:string]: string;
}
If there are no known options just the [key:string]: string;
part. But having it as a named (and exported) type is usually handy when you need to pass those options through several function calls and have them typed.
Of course I'm not saying 100% of the time you must do this, but if it is a public API there are many benefits in having a named type for this, IMO.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we don't have known properties on the object, instead of using [key:string]: string;
we have the Record
that was introduced in TypeScript, we can replace it with Record<string, string>
;
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we can replace it with
Record<string, string>
+1
I prefer we don't create a global [key:string]: string;
helper, as I think it may lead people to abuse it when they don't want to define a proper interface.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we can replace it with Record<string, string>
+1
Nice! Completely missed that! Works like a charm! ⭐
I prefer we don't create a global [key:string]: string; helper, as I think it may lead people to abuse it when they don't want to define a proper interface.
This is likely to happen anyways, isn't it? They can even use any
😂 . But yeah, totally get your point, let's keep those to a minimum until we know what's really useful and valuable! 👍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jbalsas yeah they will still do it, but at least its not an encouraged type by us creating a unique helper for me.
const div = document.createElement('div'); | ||
|
||
div.innerHTML = `<br>${htmlString}`; | ||
|
||
div.removeChild(div.firstChild); | ||
div.removeChild(div.firstChild!); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure if there's a better way... plaguing our codebase with non-null assertions doesn't feel right, yet it's certainly a powerful tool 😉
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I agree, as it is explicit above that we have a children I think this makes it safe
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
plaguing our codebase with non-null assertions doesn't feel right
I agree that it doesn't feel right to use these everywhere. If we do use it, I would prefer we have an explicit comment for why we are using it. So for this case, we can comment saying the <br>
guarantees that there is a first node.
*/ | ||
export default function getPortletNamespace(portletId) { | ||
export default function getPortletNamespace(portletId: string) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Technically, this takes any
... so... runtime type guards, keep'em or drop'em? Should we be stricter with types like this, or better type as our current usage and then clean it up?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
runtime type guards, keep'em or drop'em?
I would say you still need them in case someone calls you from plain JavaScript...
However, if we decide to do runtime type checks, we should create a small utility for that, so that code is cleaner and less error prone. Something like:
Ensure.isString(portletId);
That we put at the top of methods.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
runtime type guards, keep'em or drop'em?
I vote for keeping them since, like @izaera mentioned, you can still run this from plain js.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would say you still need them in case someone calls you from plain JavaScript...
The thing that worries me here is that we're throwing TypeError
exceptions on a bad call. It's not like we're gracefully putting the system in a place where it can recover, but rather crashing it to the ground at runtime.
This case is particularly tricky because the main difference is that if a developer calls this like getPortletNamespace(3)
it will get:
// With runtime type guards
Uncaught TypeError: portletId must be a string
// Without
_3_
At runtime, the uncaught error seems more harmful, although the silent valid return can let issues go unnoticed to the developer for a while.
The more common case I guess would be something like addParams(3, 'http://www.liferay.com')
// With runtime type guards
Uncaught TypeError: Parameter params must be an object or string
// Without
Uncaught TypeError: params.trim is not a function
Now, I'm not convinced one way or another, so just trying to keep the conversation going here.
It's not like we have so many of these anyways, but maybe we could keep the runtime type checks for utilities that get exported globally (ie: accessible through Liferay.foo
) but not for those that need you to import {foo} from 'frontend-js-web
', assuming the latter will be more easily moved into TS
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree that runtime failures are nasty, but people can avoid them simply by using a linter or TS at build time. If they don't want to do that, then they are exposed to more frequent runtime failures, because that's one of the reasons why typed languages were invented 🤷 .
Also, not throwing is much worse because you are deferring the failure to -basically- a random point in the future or, even worse, let it fail silently in ways that are very hard to reason about.
Say the developer writes addParams(3, 'http://www.liferay.com')
and, instead of failing with Uncaught TypeError: params.trim is not a function
, the code simply ignores the second parameter (because it uses an Object.keys
or something like that, instead of trim
).
Then, the method invocation would completely ignore the 'http://www.liferay.com'
but the developer would be quite puzzled because all he sees is that he's passing the option (correctly, he thinks) but the method is not honoring it. How long will it take to realize what is the error?
And in production, this will lead to some kind of functional failure which users will suffer too, so, is that better than failing fast with an exception? I doubt it...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With TS we can still get runtime errors
Elm has entered the chat
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
😂
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I really don't want to make a distinction between people who choose TS and those who choose JS
It's not a distinction between TS and JS but between having a build with type checking or no build at all. Wether that type checking build is implemented with TS, eslint, or any other tool is an implementation detail.
Regarding runtime checks, I'm a big fan of super-robust public APIs for typed languages as well as dynamic ones, so I would make them (and probably design some way to make them automatic, not hand made), as I said above 🤷
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's not a distinction between TS and JS
I was referring to this part 😉
... people using plain JS are on their own (and should suffer, at least a bit, for choosing not to use types).
But, I understand what you said about runtime checks
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
url: string | URL, | ||
listeners?: {[key: string]: Callback} | ||
) { | ||
url = url instanceof URL ? String(url) : url; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We do a lot of type narrowing that TS can't pick up like in here. In this case, instanceof
sounds like a better approach overall, but wondering what we do in general.
We're likely going to need to rewrite code because this code style is not easily expressed with types (that I could figure). There are other ways to guard types like user-defined or creating intermediate helper methods like getURLString(url: string | URL): string
that help TS here.
Wondering how this will shape out in some of the more complex isomorphisms we have 😈
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have a similar in FilePath I think...
I use the AnyPath type for string | FilePath
and FilePath.coerce to do the cast.
@@ -95,7 +94,7 @@ export default function createPortletURL(basePortletURL, parameters = {}) { | |||
let namespace = ''; | |||
|
|||
if (Object.entries(parameters).length) { | |||
namespace = getPortletNamespace(portletID); | |||
namespace = getPortletNamespace(portletID!); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For sure portletID
can be null
here. That's a possible bug somewhere that we haven't yet seen. Thoughts on what should we really do here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Few lines above:
if (Object.entries(parameters).length && !portletID) {
throw new TypeError(
'Portlet ID must not be null if parameters are provided'
);
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Generally speaking, I guess we could replace all manual type checks with TS. Unless we want them in runtime, to show some legitimate case in an error log 🤔
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So that should be a ||
😂 ?
We can add this one to the list of TS wins... doubt we would've ever seen that otherwise (other than with a bug report).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You see, the thing is that if parameters aren't provided, then portletID
can be null, but then getPortletNamespace
takes (or could take) non-nullable strings. That's the thing I wanted to raise the attention to. Need to decide how much we lean on the type system. I think the more the better, but maybe in this case null
is a valid thing to pass to getPortletNamespace
which doesn't seem to be the case since it will throw a TypeError
in that case.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If parameters are not provided, we never reach getPortletNamespace
:
if (Object.entries(parameters).length) {
namespace = getPortletNamespace(portletID);
}
In this case, the value of portletID
, that we maybe took from basePortletURL
, does not matter.
A bug here might be that we are not checking and namespacing params in basePortletURL
. But, I'm guessing we are assuming the basePortletURL
will be used as intended, a base URL with no portlet-specific params.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If parameters are not provided, we never reach getPortletNamespace:
lol, you're totally right... need to get me some better reading glasses 👴
I wonder why TS is unable to infer that portletID
can't be null
at that point. Maybe this could be written in a way that it would make it possible similar to what happens in #1379 (comment)
For this case I'll simply as string
, then.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe putting everything inside one if
...
let namespace = '';
if (Object.entries(parameters).length) {
if (!portletID) {
throw new TypeError(
'Portlet ID must not be null if parameters are provided'
);
}
namespace = getPortletNamespace(portletID);
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, that should do it!
expect(() => addParams(1)).toThrow(TypeError); | ||
|
||
expect(() => addParams(['one', 'two'])).toThrow(TypeError); | ||
expect(() => addParams(1 as any, sampleUrl)).toThrow(TypeError); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Another annoyance of keeping runtime type guards around... in tests, to test them you need foo as any
:)
Funny enough, the second test was passing because the implementation throws TypeError
from 2 different code branches, not because typeof [] !== 'object'
😉
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder if we can just remove these runtime tests because we are essentially already testing types via ts compiler. 🤷
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think that if we keep the runtime typechecks, then we'd likely want to keep the tests as well?
Following the same reasoning, there's no guarantee people will call this from TS so chances are we won't be testing types?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, that makes sense. If we have code that asserts a certain runtime error, we should test against it. 🏆
|
||
describe('Liferay.Util.PortletURL.createActionURL', () => { | ||
it('returns a URL object with a href parameter containing the p_p_lifecycle parameter set to 1', () => { | ||
Liferay = { | ||
ThemeDisplay: { | ||
getPortalURL: jest.fn(() => 'http://localhost:8080'), | ||
}, | ||
}; | ||
} as any; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mocking can get tricky... 🤔
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes. But that's expected. That's why you have to use libraries like mockito in Java, too 🤷
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe using as const
can be more secure, at least we can have the viability of what we have inside the Liferay mock.
ci:stop |
❌ ci:test:relevant - 0 out of 1 jobs passed in 15 minutesClick here for more details.Base Branch:Branch Name: master Upstream Comparison:Branch GIT ID: ce10eb3c2e2f3427b904c078ac8192ca5b7e8d37 ci:test:relevant - 0 out of 1 jobs PASSED1 Failed Jobs:For more details click here.Failures unique to this pull:For upstream results, click here.Test bundle downloads:
|
Jenkins Build:test-portal-acceptance-pullrequest(master)#669 Jenkins Report:jenkins-report.html Jenkins Suite:relevant Pull Request:liferay-frontend#1379 Testray Routine:EE Pull Request Testray Build:[master] ci:test:relevant - jbalsas > liferay-frontend - PR#1379 - 2021-08-20[02:38:48] Testray Importer:publish-testray-report#1819 |
@@ -47,10 +47,10 @@ export default function render( | |||
}, | |||
container: Element | |||
) { | |||
if (!(window.Liferay as any).SPA || (window.Liferay as any).SPA.app) { | |||
if (!Liferay.SPA || Liferay.SPA.app) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should definitely provide typings for Liferay
so that people can use it as documentation 👍
When I did the sample Angular project for the JS Toolkit, I had to use declare Liferay: any
because we were lacking that, but it was clear that publishing the types for our Liferay
interface would be a win (at least for the public parts).
@@ -17,6 +17,8 @@ import ActionURLScreen from './screen/ActionURLScreen'; | |||
import RenderURLScreen from './screen/RenderURLScreen'; | |||
import {getUrlPath} from './util/utils'; | |||
|
|||
import * as Types from 'frontend-js-web/src/main/resources/META-INF/resources/liferay/types'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
😍
@@ -16,6 +16,7 @@ import SubscriberMap from './SubscriberMap'; | |||
import deepFreeze from './deepFreeze'; | |||
|
|||
import type {Immutable} from './types'; | |||
import * as Types from 'frontend-js-web/src/main/resources/META-INF/resources/liferay/types'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
😍
State: typeof State; | ||
}; | ||
export interface ILiferay { | ||
State?: typeof State; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How does this work? Is it merged with SPA
in https://github.com/liferay-frontend/liferay-portal/pull/1379/files#diff-10174dbf5a248993084321a6662596ff7faa394aed5696d0fd001030e3544a0bR140 ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, it'll get merged with whatever global declaration of the interface is there. The thing is you need to make sure everyone contributing those types is loaded all the time which is not the case currently... need to figure out a different approach, likely involving @types
and typeRoots
or sth like that to avoid the explicit imports... 🤔
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Of course, even better would be if we stopped using the Liferay
global object 😂
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Of course, of course,... 😅
*/ | ||
export default function addParams(params, baseUrl) { | ||
export default function addParams(params: string | object, baseUrl: string) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
{ [key:string]: string }
... does that have a universal name ?
Nope, but we can create one if it is very common...
I usually create types for this kind of option arguments. So something like this I think it's possible:
addParamsOptions {
knownOption1: string;
knownOption2: boolean;
[key:string]: string;
}
If there are no known options just the [key:string]: string;
part. But having it as a named (and exported) type is usually handy when you need to pass those options through several function calls and have them typed.
Of course I'm not saying 100% of the time you must do this, but if it is a public API there are many benefits in having a named type for this, IMO.
*/ | ||
export default function getPortletNamespace(portletId) { | ||
export default function getPortletNamespace(portletId: string) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
runtime type guards, keep'em or drop'em?
I would say you still need them in case someone calls you from plain JavaScript...
However, if we decide to do runtime type checks, we should create a small utility for that, so that code is cleaner and less error prone. Something like:
Ensure.isString(portletId);
That we put at the top of methods.
url: string | URL, | ||
listeners?: {[key: string]: Callback} | ||
) { | ||
url = url instanceof URL ? String(url) : url; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have a similar in FilePath I think...
I use the AnyPath type for string | FilePath
and FilePath.coerce to do the cast.
|
||
describe('Liferay.Util.PortletURL.createActionURL', () => { | ||
it('returns a URL object with a href parameter containing the p_p_lifecycle parameter set to 1', () => { | ||
Liferay = { | ||
ThemeDisplay: { | ||
getPortalURL: jest.fn(() => 'http://localhost:8080'), | ||
}, | ||
}; | ||
} as any; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes. But that's expected. That's why you have to use libraries like mockito in Java, too 🤷
|
||
// @ts-ignore | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a way to @ts-ignore
the whole file?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, you can do // @ts-nocheck
, but then I wouldn't be able to slowly clean them up 😉
export interface ILiferay { | ||
after(eventName: string, callback: Callback): void; | ||
before(eventName: string, callback: Callback): void; | ||
on(eventName: string, callback: Callback): void; | ||
once(eventName: string, callback: Callback): void; | ||
onceAfter(eventName: string, callback: Callback): void; | ||
} | ||
|
||
// Properties from `frontend-js-spa-web/init` | ||
|
||
export interface ILiferay { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will the second export interface ILiferay
override the first?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, they merge as per Declaration Merging which is what I thought we could leverage to compose them from different modules.
In here, I just went with it to clearly outline the different origin of the properties, but that should be moved to its own module at some point. Or not 🤷
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Interesting! Looks powerful, but also quite quirky, with a lot of edge cases.
Not sure what you mean by testing TS with. We do have a short TS Guidlines doc and especially with react we recommend react-typescript-cheatsheet. Honestly though, I think the most helpful thing is to check in other places how TS is used, it is one of those tools that can be really overdone and you spend 95% of your time creating complex type structures. Generally I like to aim for simple and concise. It is going to take an external use a significant amount of time just to understand the type, I think we should go simpler. I'll check out the code here on Monday, I won't have time to give it a deep dive yet. |
Hey @bryceosterhaus!
Pardon my english 😂 . By testing I meant "trying it out". We rolled it out in some of those projects so that we could get some hands-on experience and some initial view on possible issues and mitigations to help others get on board easily. So, my question is... what were those learnings? Which things went well, what helped, what didn't...? Hope that makes it clear-er :) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Overall, this looks pretty good to me! It doesn't look nearly as bad as I might have originally thought it would.
So, my question is... what were those learnings? Which things went well, what helped, what didn't...?
I don't think there were any learnings that were concrete or "one-size-fits-all" enough to document.
Personally, at a high-level view I have made a few observations about TS
- Migrating existing code to TS is usually harder than I originally think
- Writing TS code as a whole is typically easier than I think
- The more TS you have, the easier it is to write and migrate TS.
- Copy TS patterns from existing code (both your own and 3rd party)
- Always aim for simple types, type gymnastics typically indicate a code smell
@@ -16,6 +16,7 @@ import SubscriberMap from './SubscriberMap'; | |||
import deepFreeze from './deepFreeze'; | |||
|
|||
import type {Immutable} from './types'; | |||
import * as Types from 'frontend-js-web/src/main/resources/META-INF/resources/liferay/types'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I actually prefer having to import types explicitly instead of them being globally available everywhere. I think this would also make sense for java devs to see types imported as well.
Plus, you can explicitly import types with ts import type {Liferay} from '/some-path/here';
*/ | ||
export default function addParams(params, baseUrl) { | ||
export default function addParams(params: string | object, baseUrl: string) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we can replace it with
Record<string, string>
+1
I prefer we don't create a global [key:string]: string;
helper, as I think it may lead people to abuse it when they don't want to define a proper interface.
const div = document.createElement('div'); | ||
|
||
div.innerHTML = `<br>${htmlString}`; | ||
|
||
div.removeChild(div.firstChild); | ||
div.removeChild(div.firstChild!); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
plaguing our codebase with non-null assertions doesn't feel right
I agree that it doesn't feel right to use these everywhere. If we do use it, I would prefer we have an explicit comment for why we are using it. So for this case, we can comment saying the <br>
guarantees that there is a first node.
*/ | ||
export default function getPortletNamespace(portletId) { | ||
export default function getPortletNamespace(portletId: string) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
runtime type guards, keep'em or drop'em?
I vote for keeping them since, like @izaera mentioned, you can still run this from plain js.
@@ -12,7 +12,7 @@ | |||
* details. | |||
*/ | |||
|
|||
const MAP_HTML_CHARS_ESCAPED = { | |||
const MAP_HTML_CHARS_ESCAPED: {[key: string]: string} = { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead of a generic object type, I think you can use as const
at the end of the object declaration.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
True that! Thanks!
Tried but didn't work since we later do:
export function escapeHTML(string: string) {
return string.replace(
HTML_ESCAPE,
(match) => MAP_HTML_CHARS_ESCAPED[match]
);
}
Which will give this lovely:
Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{ readonly '"': """; readonly '&': "&"; readonly "'": "'"; readonly '/': "/"; readonly '<': "<"; readonly '>': ">"; readonly '`': "`"; }'.
No index signature with a parameter of type 'string' was found on type '{ readonly '"': """; readonly '&': "&"; readonly "'": "'"; readonly '/': "/"; readonly '<': "<"; readonly '>': ">"; readonly '`': "`"; }'
I think it would work if we did something like a switch statement or if-elses with MAP_HTML_CHARS_ESCAPED['>']
...
Going to go with the Record<string, string>
approach for now. Do you see a better way to tackle this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah I think I would just go with Record for now. I tried to mess around with the file a bit to get the types really locked in, but it required some gymnastics and ugly type declarations. Even though I was able to get the types accurate, I think the complexity of the declarations made it more complex than needed.
@@ -24,7 +24,7 @@ const MAP_HTML_CHARS_ESCAPED = { | |||
|
|||
export {MAP_HTML_CHARS_ESCAPED}; | |||
|
|||
const MAP_HTML_CHARS_UNESCAPED = {}; | |||
const MAP_HTML_CHARS_UNESCAPED: {[key: string]: string} = {}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
as const
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Similar to #1379 (comment) and on top of that, we also do:
Object.entries(MAP_HTML_CHARS_ESCAPED).forEach(([char, escapedChar]) => {
MAP_HTML_CHARS_UNESCAPED[escapedChar] = char;
});
Which also requires string-based key access. I get the feeling this could be reworked somehow if we wanted, but should be okay for now...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah thats cool with me. Part of the issue is that TS makes the types a little more generic when you use methods on Object
. For example, Object.keys
returns Array<string>
, instead of the explicit key names event when it is declared as const
.
return string.replace(HTML_UNESCAPE, (match) => { | ||
return new DOMParser().parseFromString(match, 'text/html') | ||
.documentElement.textContent; | ||
.documentElement.textContent!; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead of asserting as non-null, I think it might be more explicit to do
.documentElement.textContent!; | |
.documentElement.textContent || ''; |
expect(() => addParams(1)).toThrow(TypeError); | ||
|
||
expect(() => addParams(['one', 'two'])).toThrow(TypeError); | ||
expect(() => addParams(1 as any, sampleUrl)).toThrow(TypeError); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder if we can just remove these runtime tests because we are essentially already testing types via ts compiler. 🤷
(Liferay.ThemeDisplay.getPortalURL as jest.Mock< | ||
string, | ||
[] | ||
>).mockRestore(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
😬
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe to avoid things like this for global objects we can create a base interface for Liferay with generic types to be able to use it in tests and another one for development.
Hey @izaera, @markocikos, @bryceosterhaus, @matuzalemsteles, I've pushed some additional conversions in case you want to take a look. I removed the global
From time to time, other things like project references or typeRoots seem to work, but then they stop working, so I'm not 100% sure yet what's going on. I'd say, following @bryceosterhaus's advice that we could start small by putting everying inside the same |
@@ -61,6 +61,7 @@ const SimpleInputModal = ({ | |||
document.querySelector(`#${namespace}form`) | |||
); | |||
|
|||
// eslint-disable-next-line @liferay/portal/no-global-fetch |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@bryceosterhaus, @kresimir-coko, this will need an adjustment to no-global-fetch
since we explicitly look for fetch.es
as a valid import which we won't have any longer... do you think we could work on this while this PR sits over here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's definitely something that we can investigate, I'll open an issue for it. Here it is liferay/liferay-frontend-projects#624
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
... which we won't have any longer.
@jbalsas care to expand on this statement? Are we going to be using both fetch.es
and fetch
or is there a PR that replaces usages of fetch.es
to fetch
. If I modify the rule to error when it sees fetch.es
DXP will scream in pain.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are we going to be using both fetch.es and fetch or is there a PR that replaces usages of fetch.es to fetch. If I modify the rule to error when it sees fetch.es DXP will scream in pain.
This PR 😉
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Awesome!
? node.documentElement | ||
: node; | ||
|
||
return element[`client${property}` as ClientProp]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Brackets access is always tricky... 🤔
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This will work in TS 4.4 since it accepts Template String Pattern Index Signatures after microsoft/TypeScript#44512!! 🎉
|
||
const transform = | ||
style.mozTransform || style.msTransform || style.transform; | ||
(style.mozTransform as string) || style.msTransform || style.transform; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure what did I do here 🤔
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it was just a leftover from before I typed it as VendorPrefixedCSSStyleDeclaration
😂
@@ -16,7 +16,7 @@ | |||
* Returns true if the specified value is an object. This includes arrays | |||
* and functions. | |||
*/ | |||
export default function isObject(val: any) { | |||
export default function isObject<T>(val: T): val is NonNullable<T> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this should infer never
when called with other types. For example, const a = 'a'; if (isObject(a)) {...}
will consider that a is of type string
instead of never
.
I like this approach. It's very simple to start with and it allows us to consolidate first and then determine the best next steps forward after. |
Oh I really like it too, it feels simpler and cleaner compared to having to import it, my 👍 to go with it 😁. |
Renames index.es.js to .ts and adjusts main entry point. Disable all checks initially so we can clean them up one by one. Additionally, adds ts-loader to webpack config and teaches it to handle TypeScript files with priority over JS ones.
This is going to be arguably the hardest thing to get right in our migration to TypeScript. The `Liferay` object is global and augmented from way too many different sources making it really hard to type. This commit aims to start simple and get us to a point where we can start adding more types as we go. For now, it simply adds the `Liferay` object and describes all the properties defined in `portal-web/top_js.jspf`. It is left for future commits to: - Refine existing types to better match the `Liferay` object - Add more types as needed - Figure out a better way to handle the `Liferay` object
- Converts autosize to ts - Converts html_util to ts because it is a dependency of autosize - Makes dependency explicit rather than implicit via the `Liferay` global With the last change, we avoid having to add `Liferay.Util.unescapeHTML` to the ILiferay interface coming from `global.es.js`. This should be the preferred approach. Consuming the proper APIs rather than reaching out to the global namespace. In `html_utils`, MAP_HTML_CHARS_ESCAPED and MAP_HTML_CHARS_UNESCAPED are somewhat badly typed. Their indexed access type should probably return `string | null`. Internally, it will never get accessed with a non-valid key, so it should be safe to assume that the type is `string` to simplify things for now.
I picked the lowest hanging modules I could find to get them out of the way. One interesting thing to note is that we have some argument type guards in place. These usually take the form of `if (typeof arg !== <type>)`. We usually check and throw an Error during runtime if the types don't match. This should no longer be necessary if we could guarantee all of our consumers were using TS. Unfortunately, that's not really the case, so the guards can still play a role albeit with diminishing returns. A side effect of this is that when we're testing the guard, TS will complain automatically about us passing the wrong type. That is, if we try to pass an object to a method that expects a number, TS will complain about it. The solution picked here is to use `as any` to cast the argument to get it through.
The most interesting part in this conversion is the code change to use `instanceof` instead of a custom if clause to guard the type and narrow down the url argument to the correct type. This is a rather common pattern in Liferay. Accepting several different arguments and then using the same variable indistinctly after some normalization steps. A possible alternative would be to create a real "normalizer" function `(url: string | URL) => string` that would take the url and return a normalized version with the type narrowed down to `string`.
Interesting note here though is the fact that TS caught a bug in our test or potentially in our implementation of `addParams`. The implementation checks wether the first argument is `string | object`. The test goes on to put that to the test passing an array which, coincidentally is an object as far as `typeof` is concerned. The test expects a TypeError to be thrown which funny enough, is thrown because the call is missing the second argument. I've decided to remove the test assertion since passing an array as params sounds like a valid use case to me even though the keys would be the number indexes.
This conversion is pretty straightforward but showcases some interesting things to consider: 1. Mocking the Liferay global object Since we declare `Liferay` as a global var, mocking it is not straightforward. - Partial mocks are flagged as TS as incompatible - Reassignments in setup or teardown methods won't get picked up by TS forcing us to cast to `Jest.Mock` and/or other types For the sake of progress I simply resorted to "as any" here, but maybe we should consider a better approach. Some possible options include: - Simply export the type and locally cast usages of the global API instead of defining a global var - Further split the definition so we can use Partial<ILiferay> and Partial<IThemeDisplay> to construct partial mocks 2. String-indexed objects We do a lot of willy-nilly object string-index access in our codebase. Maybe as we progress our types will become more explicit, but for now it seems like we want the type `{ [key: string]: string }` more often than not. - Does this type have a common name? - Should we export this type for general consumption or define it locally? 3. To TS or not to TS This stems a bit from the previous point and connects with prior commits where we addressed the fact that we keep runtime type guards in our code. In this case, we consume `getPortletNamespace(portletID: string): string`, but do so trying to pass `portletID: string | null`. This hints that we might have cases of `createPortletURL` throwing exceptions in runtime through the `getPortletNamespace` utility. Again, in the spirit of progress, I just added a non-null assertion here `portletID!` which we know is obviously wrong. Updating the definition to `getPortletNamespace(portledID: string | null)` seems to be technically correct but pushes the problem down the system. We likely don't want a system where `null`s are being passed around unknowingly. I'm not sure what the best approach is here, so just writing this down here to see how this goes, specially as we move onto other modules.
Without this, TS sees `portletID: String | null`, probably because it can't figure out that the 2 disjoint conditions over `params.length` force `portletID` to actually be just `String`.
Disabled temporarily @liferay/portal/no-global-fetch because a valid import depends on the `.es.js` extension which is no longer the case. Will need to fix `eslint-config/.../no-global-fetch.js` before merging
This is obviously not a TS migration :) The `CompatibilityEventProxy` class added compatibility between YUI and metal events, re-emitting events according to YUI naming and adding the capability of adding targets to bubble events to them. Only usage I'm aware of was in an old version of TranslationManager which was already updated as part of [Update TranslationManager component](https://issues.liferay.com/browse/LPS-103180) which initially removed the dependency at liferay@a4336c0
- [HTMLCollection should be string indexable](microsoft/TypeScript#19437). But it isn't, so `form.elements[elementName: string]` is a no go. I decided to go with `HTMLFormControlsCollection.namedItem()` instead which should do about the same. - Improves `isObject` help the compiler infer the type resulting after it by returning a non-nullable version of same type passed to it. However, it won't return `never` for `isObject(foo: string | boolean | number)` so it can still use some further refinement.
…_dom utilities to TS
Well... I think it's obvious that I won't be around long enough to see this done... 😢 I just rebased and pushed the branch with what I had so far. I hit a bit of an egg and chicken type of problem with the current approach of a I'll keep this repo and branch around for some time, but it would be advisable for someone to keep track of it independently just in case I get trigger-happy with the "delete repo" button 😂 |
We actually have been meaning to tell you, you aren't allowed to leave until this is done... Glad to have you around awhile! 😂 |
I pulled this, rebased and ran Most of the errors seem to be coming from The commit containing changes triggered by @bryceosterhaus I guess next steps would be to get rid of these errors so that it can be actually deployed |
I guess you're right 👍 |
Closed in favor of #1732 |
Hey @liferay-frontend!!
This is an initial exploration of what a migration to TS would look like for some of our modules and how ready our infrastructure is for other teams to safely adopt TS in their own modules.
I'm sharing so we can discuss about the patterns and possible impediments we have to fully transition to TS and make sure our infra is solid for other teams to move onto it soon.
Each commit message has some information about specific topics that might be worth discussing, and I'll be highlighting some things in particular, but feel free to review this at your will and raise any questions you might think of.
@bryceosterhaus, @matuzalemsteles, @izaera, mentioning you in particular since you might have the most experience in TS codebases... did we ever codify some guidelines or best practices in Clay or the other repos that we used to test TS with? Any hints to share? Thoughts? 👼