Getting started with TypeScript

Having created a new project with the --typescript flag, I naively assumed I’d get a starter theme with TypeScript to work on, but since Mars isn’t written in TS, I don’t.

I’m happy to create my own theme from scratch but I’d suggest that the onboarding and documentation here could be better - maybe a TS starter theme would be useful. As it stands I need to work out what dependencies to add to the theme’s package.json to get started with TS which doesn’t really gel with Frontity’s claim to have first-class TS support.

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 :slightly_smiling_face:

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 :slight_smile:

3 Likes

Hi @luisherranz ! Can we find any repository with this approach in typescript? Thanks!

The theme of frontity.org was made with TypeScript: https://github.com/frontity/frontity.org/tree/dev/packages/frontity-org-theme

Apart from that, we are right now experimenting with dividing the types in two, as explained in this thread: TypeScript: use two different types. We really like it so far :slightly_smiling_face:

Finally, there is an issue in wp-source that want to solve using typeguards. Basically, we are going to promote the use of functions instead of properties to differenciate between different data objects in TypeScript:

// JavaScript file
const Comp = ({ state }) => {
  const data = state.source.get(state.router.link);

  if (data.isArchive) {
    data.taxonomy; // <- Works fine in TypeScript prior to 3.9.
  }
};
// TypeScript file
import { isArchive } from "@frontity/source";

const Comp = ({ state }) => {
  const data = state.source.get(state.router.link);

  if (isArchive(data)) {
    data.taxonomy; // <- Works fine in TypeScript +3.9.
  }
};

It is explained in this thread Extend source's Data interface with types from new handlers. Be careful, it’s a really long thread.

We need that to fix our types in TypeScript 3.9, so I guess it’s not going to take long.

Hi @luisherranz, thanks for the info. I will take a look to Frontity’s theme :slight_smile:

1 Like