TypeScript: use two different types

I’ve done a video to explain how a frontity.settings.ts file works:

Link to Loom

It looks like we are going to need a MergePackages util to solve this problem:

import { MergePackages } from "frontity/types";

export type Packages = MergePackages<SomePackage, OtherPackage>;

I’ve played around with the implementation and came up with the following type for merging the packages. Works great, the only thing that I don’t like is that the Packages have to be passed as a tuple Merge<[Package1, Package2]> instead of individual parameters like Merge<Package1, Package>:

// Change the type of the package `name` from the string literal
// like "@frontity/wp-source" to just `string`.
type NameToString<T> = 'name' extends keyof T 
  ? Omit<T, 'name'> & { name: string }
  : T;

type MergePackages<T extends unknown[], U = T[number] > = 
  (U extends any ? (k: NameToString<U>)=> void : never) extends ((k: infer I)=> void) ? I : never


// ---------- Example

type Package1 = { name: 'a', x: string, y: string}
type Package2 = { name: 'b', z: string}

const packages1: MergePackages<[Package1, Package2]> = { name: 'test', x: 'hello', y: 'hi', z: 'hola' }

You can check it out in the TS playground:

Thanks Michal! I’ll add it to the https://github.com/frontity/frontity/tree/package-name branch and investigate if I can make it work with args instead of tuples :+1:

1 Like

I would do something like this:

type MergePackages<
  P1 extends Package,
  P2 extends Package,
  P3 extends Package = {},
  P4 extends Package = {},
  P5 extends Package = {},
  P6 extends Package = {},
  P7 extends Package = {},
  P8 extends Package = {},
  P9 extends Package = {},
  P10 extends Package = {}
> = Omit<P1, "name"> &
  Omit<P2, "name"> &
  Omit<P3, "name"> &
  Omit<P4, "name"> &
  Omit<P5, "name"> &
  Omit<P6, "name"> &
  Omit<P7, "name"> &
  Omit<P8, "name"> &
  Omit<P9, "name"> &
  Omit<P10, "name">;

type Packages = MergePackages<Package1, Package2, Package3>;

I know it’s really ugly compared to Michal’s solution, but I think that tuples is less standard in terms of syntax 🤷

Also, I think we can remove name instead of replacing it for a string because it’s not required for that use. And if someone wants to extend several packages at once that were merged using MergePackages he/she can add it back.

interface MyPackage extends MergePackages<Package1, Package2> {
  name: "my-package";
  // ...
}

yeah, that’s fine because it’s an implementation detail :+1: Actually, I think it’s a better solution than my typescript jiu jitsu :sweat_smile:

1 Like

Final implementation

New types

  • Added the MergePackages utility. It simply removes the name property from each package and returns the intersection of all of them, as specified in the implementation proposal. It is exported from frontity/types.

    import { MergePackages, Derived } from "frontity/types";
    import Source from "@frontity/source/types";
    import Router from "@frontity/router/types";
    
    interface MyPackage {
      state: {
        // Derived prop that depends on source and router packages.
        someProp: Derived<Packages>;
      };
    }
    
    // All packages merged together.
    type Packages = MergePackages<Source, Router, MyPackage>;
    
  • Moved the frontity namespace from Package to the new Frontity interface. Now, if you need to access properties like state.frontity.url, state.frontity.rendering, etc. you have to import Frontity from frontity/types and include it in MergePackages as any other package.

    import { Frontity, MergePackages, Derived } from "frontity/types";
    
    interface MyPackage {
      state: {
        // Derived prop that depends on the frontity namespace.
        someProp: Derived<Packages>;
      };
    }
    
    // All packages merged together.
    type Packages = MergePackages<Frontity, MyPackage>;
    

Other changes

  • Adapted all packages to use MergePackages and Frontity types if needed.
  • Refactored the Settings type to improve package types resolution.
1 Like

This feature has been included in the latest release :tada: : Read the full Release Announcement .

1 Like

This is a video of @orballo @david and I refactoring a complex theme @orballo is working on, with the new types released in the last version.

We introduced:

  • The new MergePackages util.
  • The new Data types.
  • Extending Data with new types, like FichasData.
  • Using the new type guards, like isPostType(data).
  • Creating new type guards, like isFichas(data).
  • The new Entity types.
  • Extending Entity, like FichasEntity.

Video:


I have to say, I am really happy with the result! I think the new types fix all the previous problems. Now everything makes sense and it is straightforward :clap:

@orballo please follow up once you have made the full refactoring. Let us know if you still have any problems!! :slightly_smiling_face:

1 Like

Hi guys!

I didn’t finish with the types we were working on yet, but while working on another project I run into a couple of things that I cannot figure out and maybe they are bugs. Please help me out here.

1. Derived with arguments is not callable

I’m trying to call a derived value that accepts an argument (Derived<Packages, number, string>) from inside another derived.

2. Action is not callable

I’m trying to call an action (Action<Packages>) from inside another action.

3. Derived that returns string cannot be used as string

I’m trying to pass a derived value that returns a string (Derived<Packages, string>) to parseFloat.

Here is the repo in case you need it https://github.com/orballo/invoice

I ran into the exact same problem yesterday :laughing:

You don’t have a tsconfig.json file in your project. Add this one:

tsconfig.json

{
  "compilerOptions": {
    "target": "es2017",
    "moduleResolution": "node",
    "module": "commonjs",
    "allowSyntheticDefaultImports": true,
    "noEmit": true,
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "jsx": "react",
    "allowJs": true
  }
}

I was also taking a look to see if we could ship a tsconfig.json file within the "frontity" package to make something like this work:

tsconfig.json

{
  "extends": "frontity"
}

That way if we make a change to our TypeScript configuration people don’t need to change their file.

It says in the docs that it uses “Node.js style resolution” so I guess it should work.

1 Like

By the way, @david, I have a question for you.

Even if we use a data type guard like this:

// Get information about the current URL.
const data = state.source.get(state.router.link);
// Do nothing is this is not a post type.
if (!isPostType(data)) return null;
// Get the data of the post.
const post = state.source[data.type][data.id];

and data is PostTypeData after the if, post is any.

That is because data.type is string in PostTypeData and therefore TypeScript is not able to know the values.

If we use isPost or isPage, then it works:

// Get information about the current URL.
const data = state.source.get(state.router.link);
// Do nothing is this is not a post.
if (!isPost(data)) return null;
// Get the data of the post.
const post = state.source[data.type][data.id];

Now post is PostEntity. But that is not useful in case you have a single component for all the post types, like in mars:

<Switch>
  <Post when={isPostType(data)} />
</Switch>

I see you have also used a trick to manually cast the type in a component by passing data from the parent and typing the compnent, like this:

<Switch>
  <Post when={isPostType(data)} data={data} />
</Switch>
const Post: React.FC<{ data: PostTypeData }> = ({ data }) => {
  // ...
};

But that is not an ideal solution for all cases, in my opinion. It would be a bit like doing this:

// Get information about the current URL.
const data = state.source.get(state.router.link);
// Do nothing is this is not a post type.
if (!isPostType(data)) return null;
// Get the data of the post.
const post: PostTypeEntity = state.source[data.type][data.id];

So my question is… do we still need to add state.source.postTypes.xxx and state.source.taxonomies.xxx to solve this, or is there any other solution?

By the way, this would be the code in that case:

// Get information about the current URL.
const data = state.source.get(state.router.link);
// Do nothing is this is not a post type.
if (!isPostType(data)) return null;
// Get the data of the post.
const post = state.source.postTypes[data.type][data.id];

Also, right now I can’t figure out a way to get rid of the !isPostType(data) check. The only one would be to do a manual cast in state.source.get():

// Get information about the current URL.
const data = state.source.get<PostTypeData>(state.router.link);
// Get the data of the post.
const post = state.source.postTypes[data.type][data.id];

@orballo this is really cool by the way!!! https://invoice.orballo.dev/ :clap: :grinning_face_with_smiling_eyes:

1 Like

Wow, really cool!! :eyes:

Thank you very much :grinning_face_with_smiling_eyes:

Right now I don’t have any other solution in mind, but I can think about that during the holidays. :slightly_smiling_face:

By the way, this doesn’t work. It was working because index.js was not typed. As soon as I migrated that to TypeScript, it started complaining that data={data} was not right because it missed properties from PostTypeData.

For that to work we have to do this to type data inside data={...}.

<Switch>
  <Post when={isPostType(data)} data={isPostType(data) && data} />
</Switch>

It is not pretty, but at least is not manual casting.

Maybe we could join when and data in the Switch component for this use case.

<Switch>
  <Post when={isPostType(data) && data} />
</Switch>
const Post: React.FC<{ when: PostTypeData }> = ({ when: data }) => {
  // ...
};

But that { when: data } is not very straightforward.

We could add a check for data to the Switch component:

<Switch>
  <Post data={isPostType(data) && data} />
</Switch>
const Post: React.FC<{ data: PostTypeData }> = ({ data }) => {
  // ...
};

It seems a better option, although not perfect. I am going to try that in the WooCommerce PoC with a custom Switch component.


Last but not least, we could add manual casting to state.source.get. I have already tried it and it works fine:

const data = state.source.get<PostTypeData>(state.router.link);

or this:

const data: PostTypeData = state.source.get(state.router.link);

But it is manual casting, after all…

Hi,

I finished refactoring of the types for my client and there is only one thing maybe worth mentioning, when working with processors I run into the following conflict:

The types for processors are assuming that props is going to be either html attributes or the css prop, but sometimes you’ll want to define new props for the component to render.

What do you think would be the best way to act here?

If I’m not mistaken, you should be able to define your own props, like this:

interface IframeElement extends Element {
  props: Element["props"] & {
    "data-src"?: string;
  };
}

const iframe: Processor<IframeElement> = {
  // ...
};

Full example: https://github.com/frontity/frontity/blob/dev/packages/html2react/processors/iframe.tsx

1 Like

@orballo did it work?

@david, what do you think about this? TypeScript: use two different types