TypeScript: use two different types

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

@David a friendly reminder here :slightly_smiling_face:

I’d like to know what you think about this TypeScript: use two different types.

Also, what I ended up doing in the WooCommerce PoC was to modify <Switch> to check for data as well:

<Switch>
  <Loading when={data.isFetching} />
  <List data={isArchive(data) && data} />
  <Post data={isPostType(data) && data} />
  <PageError data={isError(data) && data} />
</Switch>

Then, get data already typed in the components:

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

This is the Switch modified to accept not only when, but also data: Switch.

Another idea to simplify this as much as possible could be to make our type guards return data | false instead of true | false.

Then, people could do the routing like this:

<Switch>
  <Loading when={data.isFetching} />
  <List data={isArchive(data)} />
  <Post data={isPostType(data)} />
  <PageError data={isError(data)} />
</Switch>

Let me know what you think about all the options, especially about the state.source.postTypes[...] and state.source.taxonomies[...] option.

cc: @mmczaplinski @cristianbote @orballo and anyone else using TS for the themes, feedback is also welcomed!

Thanks for the reminder, @luisherranz!

I didn’t think about it during Christmas (as I said I would), and I’ve been a bit busy these last weeks. So, to not let more time pass I decided to schedule a slot of time tomorrow to give this a tought.

I’ll come back with an answer after that. :blush:

I was thinking about that and no other solution came to my mind.

Currently, each entity that comes from the WP REST API is stored inside a state.source[type] map. The value of type is the specific type of the entity: post, page, category, author, etc.

If you have a data object typed as PostTypeData, as the type of the type property is string, there’s no way to know – at compile time – which map is the entity stored in. Therefore, it’s not possible to know the type of source[data.type] (infered as any).

The only way to do so would be typing data.type in some way. Maybe we could create a type that would return the keys of those entity maps that extends Record<PostTypeEntity>. For example, a generic PostTypes<Packages> could return "post" | "page" and we could use it to cast data.type.

I don’t think this would make sense because, if you have to cast data.type, why not to cast source[data.type][data.id] to PostTypeEntity directly? And manual casting is actually the thing we are trying to avoid.

Following your proposal, at least we could know that all the entities inside postTypes have the PostTypeEntity type. The only drawback I see here is that accessing entities would be more verbose.

Another option could be to have a way to abstract where entities are stored using a function to retrieve them, a function that would receive a typed data object and would return a typed entity.

Something like: state.source.entity(data: PostTypeData): PostTypeEntity

I think this is something I’ve already proposed and we dismissed it because it wasn’t extensible (if I remember correctly).

So, yeah, I don’t have any other idea. I think your’s is OK. :+1:

Oh, nice. I like that. :slightly_smiling_face:

1 Like

Thanks David :slightly_smiling_face:

Both casting data.type and using a function that returns the correct type are interesting options!!

Although I agree with you that using state.source.postType[data.type] is a better option here:

state.source.postType(data.type, data.id);
// vs
state.source.postType[data.type][data.id];

Ok, if nobody else has any other idea then I guess we will go with those adding derived state for backward compatibility.

Hi,

I’ve also encountered some issues with the Switch component. I’m using typescript in a strict mode so my first problem was the type of the data passed to components inside the Switch, and the second - when prop which was not defined in my components. I didn’t want to add this prop to each component type, because the components themselves should be independent and they do not need to know they are used inside the Switch. So I came up with a solution: I’ve created a simple yet functional Case component in the following form:

type CaseProps = Connect<{
	when?: boolean;
}>;

const Case: React.FC<CaseProps> = ({ children }) => (
	<>
		{children}
	</>
);

export default connect(Case);

So it actually does nothing but allows me to use Switch component with strict mode in typescript like this:

<Switch>
	<Case when={condition}>{/* TypeScript knows the `when` prop so it does not complain in strict mode */}
		<Example />
	</Case>
	<Case when={isPage(data)}>
		{/* Here we still need to cast the type, but it's quiet obvious that inside this Case  `data` really IS of type PageData, we just need to tell typescript that we know better. */}
		<Page data={data as PageData} />
	</Case>
	<Case when={isPost(data)}>
		<Post data={data as PostData} />
	</Case>
</Switch>

IMO this looks cleaner and more intuitive. Maybe something like this could be added to Frontity…?

2 Likes

That’s very interesting. Thanks for sharing @wojtek!

I’ve been playing a little bit with that as well. Especially with ways to type the data that is passed to the children.

The problem is that, if you separate them into two props, you can’t take advantage of the type guard.

Imagine a <Shop> component that accepts a ProductArchiveData data:

const Shop: React.FC<{ data: ProductArchiveData }> = ({ data }) => {
  // ...
};

If you use one prop, the type guard can correctly type the data:

<Shop data={isProductArchive(data) && data} />

But if you use two props, it can’t. Here data is not ProductArchiveData:

<Switch>
  <Shop when={isProductArchive(data)} data={data} />
</Switch>

In one project I ended up modifying the <Switch> component so it also uses the data prop to do the conditional:

<Switch>
  <Loading when={data.isFetching} />
  {/* These are the new pages introduced for WooCommerce */}
  <Shop data={isProductArchive(data) && data} />
  <Product data={isProduct(data) && data} />
  <Cart when={isCart(data)} />
  <Checkout when={isCheckout(data)} />
  <Order data={isOrder(data) && data} />
  {/* These are the normal WordPress pages */}
  <List data={isArchive(data) && data} />
  <Post data={isPostType(data) && data} />
  <PageError data={isError(data) && data} />
</Switch>

That way you also avoid the need to type a when prop, like in your case. But I don’t like that API. Using data instead of when is not intuitive at all for anyone reading the code.

In your case, you could use the type guard for the casting, but it doesn’t feel an improvement over your method:

<Switch>
	<Case when={isPage(data)}>
		<Page data={isPage(data) && data} />
	</Case>
</Switch>

So I still wonder if there’s a way to type data with a nice API :grinning_face_with_smiling_eyes:


By the way, I think your <Case> component would make a great addition to Frontity for people using TypeScript! Will you be willing to do a small contribution to add it to the @frontity/components package? :slightly_smiling_face: