You’re absolutely right @colin, I am sorry about that. We thought about adding a mars-theme-typescript
theme but we never did and nobody complained so I guess we let it go.
Actually, I thought I removed the --typescript
option a while ago in this PR: https://github.com/frontity/frontity/pull/243. I think @mmczaplinski fixed some problems and added it back
There’s quite a lot to review here, let me go step by step.
Starter themes in TypeScript
To avoid maintaining two versions, I was wondering if we can migrate mars-theme
to TypeScript and use tsc
to generate a version with ES2020 target that is useful for people that want to use the JavaScript version.
I just went ahead and tried migrating mars-theme
to TypeScript and using tsc
to compile it down to JavaScript and I found two problems:
-
tsc
doesn’t preserve the line breaks.
-
tsc
removes the comments inside JSX.
Other than that, I think it seems to work pretty.
I tried using esformatter
to add the lines back. The only problem is that React fragments without name <>
are not supported, but they can be easily converted to <React.Fragment>
before running esformatter
and back to <>
after.
The result is not perfect because esformatter
doesn’t have that many options, but maybe it’s good enough. These are the options I used in case they are useful in the future:
{
"lineBreak": {
"before": {
"FunctionDeclaration": ">=2",
"VariableDeclaration": ">=2",
"IfStatement": ">=2",
"ReturnStatement": ">=2"
},
"after": {
"FunctionDeclaration": ">=2",
"VariableDeclaration": ">=2",
"IfStatement": ">=2"
}
}
}
I also applied prettier
after esformatter
.
But as this is a complex process and we have to think it carefully, I would just keep two separate versions.
TypeScript support in Frontity
I’m really sorry the documentation is not there yet in terms of TypeScript, please bear with us. We were a very small team when we launched the framework, 2 engineers for the whole framework and documentation. Now we are 4 engineers and 2 DevRels. The DevRels are right now rethinking and reorganizing the documentation from scratch. They will include TypeScript sections as well, and we even started talking about adopting TSDoc in our codebase to make TypeScript documentation easier to update.
But what we say about first-class TypeScript support is true!
Even though we don’t have docs for TypeScript, all Frontity is coded with TypeScript and we are helping several companies to migrate to Frontity themes written in TypeScript.
To work with TypeScript in Frontity all you need to do is to generate a types.ts
file in your root (it’s not required but it is the standard) with a single interface that describes your whole package.
For example, this would be basic types.ts
of mars-theme
:
// It can extend from Package if you want.
import { Package, Action } from "frontity/types";
interface MarsTheme extends Package {
name: "@frontity/mars-theme";
roots: {
theme: React.FC;
};
state: {
theme: {
menu: string[][];
isMobileMenuOpen: boolean;
featured: {
showOnList: boolean;
showOnPost: boolean;
};
};
};
actions: {
theme: {
toggleMobileMenu: Action<MarsTheme>;
closeMobileMenu: Action<MarsTheme>;
};
};
}
export default MarsTheme;
This type is the key. Then, you import this type in the rest of your package.
The only thing that needs more advanced typings than just JavaScript are:
- Actions.
- Derived state.
- Connected components
Actions
Actions are created using Action<MyPackage>
with optional arguments (if they have):
import { Action } from "frontity/types";
import MyPackage from "../../types";
const justAction: Action<MyPackage> = ({ state }) => {
/* ... */
};
const actionsWithArgs: Action<MyPackage, string, number> = ({ state }) => (
str,
num
) => {
/* ... */
};
// Use later like this:
const Comp = ({ actions }) => {
actions.mypackage.justAction();
actions.mypackage.actionsWithArgs("str", 123);
};
That was only an example, most of the times you won’t want to redeclare your types, so you can get them from the main type:
// my-package/types.ts
interface MyPackage {
actions: {
justAction: Action<MyPackage>;
actionsWithArgs: Action<MyPackage, string, number>;
};
}
export default MyPackage;
// my-package/src/actions.ts
import MyPackage from "../../types";
const justAction: MyPackage["actions"]["justAction"] = ({ state }) => {
/* ... */
};
const actionsWithArgs: MyPackage["actions"]["actionsWithArgs"] = ({
state,
}) => (str, num) => {
/* ... */
};
We keep this main type out of the src
folder so other packages can easily import it with this standard "package-name/types"
syntax:
import MyPackage from "my-package/types";
Derived State
Derived state is similar to Action, but the last argument is the return:
import { Derived } from "frontity/types";
import MyPackage from "../../types";
const justDerived: Derived<MyPackage, number> = ({ state }) => {
return 123;
};
const derivedWithArgs: Derived<MyPackage, string, number> = ({ state }) => (
str
) => {
return 456;
};
// Use later like this:
const Comp = ({ state }) => {
state.mypackage.justDerived; // <- 123
state.mypackage.derivedWithArgs("str"); // <- 456
};
Again, you will want to declare those types in your main types.ts
file and read them from there.
Connect
Your connected React components use Connect
to create the props for React.FC
with both your main type MyPackage
and the real props of that component.
import { connect } from "frontity";
import { Connect } from "frontity/types";
import MyPackage from "../../types";
type Props = Connect<MyPackage, { str: string }>;
const Comp: React.FC<Props> = ({ state, actions, str }) => {
// ...
};
export default connect(Comp);
useConnect
We are about to release the useConnect()
hook, and it also works with your main type. You don’t need to merge the props here, of course, so it’s a bit simpler.
import { connect, useConnect } from "frontity";
import MyPackage from "../../types";
const Comp: React.FC<{ str: string }> = ({ str }) => {
const { state, actions } = useConnect<MyPackage>();
// ...
};
export default connect(Comp);
As you can see, everything follows the same pattern:
- You create the main type.
- You use that type everywhere.
Package dependencies
Finally, if your package needs to be aware of other packages, you need to import the APIs in your types.ts
declaration.
In Frontity, there may be multiple implementations for the same namespace. When that happens, there is a “root” package that contains the types. So far that happens for these namespaces but more will be added in the future:
-
router
: @frontity/router
-
source
: @frontity/source
-
analytics
: @frontity/analytics
Let’s see analytics
for example. We are currently working on these three packages which will be released next month:
@frontity/google-analytics
@frontity/gtm-analytics
@frontity/comscore-analytics
They all use the analytics
namespace. What this means is that, if your theme wants to send events to an analytics service, it can do so by using actions.analytics.event("some event")
and it’s up to the user or your theme to install one or multiple analytics packages.
So let’s imagine your theme wants to send an event when a specific component is loaded, for example a Share
component:
import { connect } from "frontity";
import { Connect } from "frontity/types";
import MyPackage from "../../types";
const Share: React.FC<Connect<MyPackage>> = ({ actions }) => {
React.useEffect(() => {
if (actions.analytics) actions.analytics.event("share", { someExtraData });
}, []);
// ...
};
The problem here is that the MyPackage
type doesn’t have an actions.analytics.event
, so you need to make sure that the types of the packages that your package needs to be aware of are imported. In this case, it’s a shared namespace, so we are going to import them from the root package @frontity/analytics
:
import { Package, Action } from "frontity/types";
import Analytics from "@frontity/analytics";
interface MarsTheme extends Package {
name: "@frontity/mars-theme";
roots: {
theme: React.FC;
};
state: {
theme: {
// ...
};
};
actions: {
analytics?: Analytics["actions"];
theme: {
toggleMobileMenu: Action<MarsTheme>;
closeMobileMenu: Action<MarsTheme>;
};
};
}
export default MarsTheme;
Now, imagine your theme needs access to state.router.link
. Again, we have to make it aware of it using the root router package. Right now there’s only one router implementation that we are aware of (tiny-router
), but in the future there may be more and your theme needs to be prepared to support them.
import { Package, Action } from "frontity/types";
import Analytics from "@frontity/analytics";
import Router from "@frontity/router";
interface MarsTheme extends Package {
name: "@frontity/mars-theme";
roots: {
theme: React.FC;
};
state: {
router?: Router["state"];
theme: {
menu: string[][];
isMobileMenuOpen: boolean;
featured: {
showOnList: boolean;
showOnPost: boolean;
};
};
};
actions: {
// ...
};
}
export default MarsTheme;
You can be more specific if you want, and import only the properties you really need:
import { Package, Action } from "frontity/types";
import Analytics from "@frontity/analytics";
import Router from "@frontity/router";
interface MarsTheme extends Package {
name: "@frontity/mars-theme";
roots: {
theme: React.FC;
};
state: {
router?: {
link: Router["state"]["link"];
};
theme: {
menu: string[][];
isMobileMenuOpen: boolean;
featured: {
showOnList: boolean;
showOnPost: boolean;
};
};
};
actions: {
// ...
};
}
export default MarsTheme;
Finally, I’d like to encourage you to try using TypeScript with Frontity and let us know of any problem you find. We may not have docs yet, but we pay a lot of attention to the community so any question you may have will be answered, so please don’t hesitate to ask.
Welcome to Frontity