Extend source's Data interface with types from new handlers

OPENING POST


Description

People using TypeScript needs to extend Data when they add a new handler. Right now, there’s no easy way to do so, because they need to extend both the BaseData and add new interfaces to Data.

This is Data, they need to add other types/interfaces here:

And this is BaseData, they need to add new properties here:

User Stories

As a Frontity developer
I want to add TypeScript for new handlers
so that I can keep using TypeScript in my theme/package

Possible solution

@mmczaplinski proposed a generic that gets the new types.

Maybe something like:

import { Package, Derived } from "frontity/types";
import { Data } from "@frontity/source";

// Define D your custom post types...
type CustomTypeData = {
 isCustomType: true;
 otherProps: ...
}

// Create a new Data with that custom post type...
type MyData = Data<CustomTypeData>;

interface MyPackage extends Package {
 state: {
   source: {
     // Use state.source.get with the correct Data.
     get: Derived<MyPackage, (link: string) => MyData>;

}

SUMMARY


:warning: Please, bear in mind that this section wasn’t part of the opening post. It has been added afterwards and its purpose is to keep a quick summary with the most relevant information about this FD at the top of the thread.

Relevant links

Final Implementation

I couldn’t get to implement a solution like the one described in the OP, because there is no way to select only the isSomething fields from a type to add to the Base.

It could be done if https://github.com/microsoft/TypeScript/issues/6579 becomes real, because we could match the keys with a regex.

I think it might be possible to build something creating a base type only with the boolean properties extracted from every data type, but this is not ideal either.

Creating a base with every single field makes no sense because then no matter what data object you were using, TypeScript would think every single field of every single data type would be present in that object, and that’s not useful.

The only solution I could think of is the following:

// source.ts

export type BaseData<T = {}> = {
  isFetching: boolean;
  isReady: boolean;
  isArchive?: false;
  isPostType?: false;
};

type ArchiveData<T> = T & {
    isArchive: true;
    items: EntityData[];
    total?: number;
    totalPages?: number;
  };

type PostTypeData<T> = T & {
  isPostType: true;
  type: string;
  id: number;
};

type Datas<T> = ArchiveData<T> | PostTypeData<T>;

export type Data<CustomBase = BaseData, CustomData = Datas<CustomBase>> =
    CustomData extends Datas<CustomBase>
    ? Datas<CustomBase>
    : Datas<CustomBase> | CustomData;

// theme.ts

import { BaseData as SourceBaseData, Data as SourceData } from '@frontity/source';

type BaseData = SourceBaseData<{
  isMyCustomPostType?: false;
  isMyOtherCustomPostType?: false;
}>;

type MyCustomPostTypeData = BaseData & {
  type: 'my-custom-post-type',
  isMyCustomPostType: true
}

type MyOtherCustomPostTypeData = BaseData & {
  type: 'my-other-custom-post-type',
  isMyOtherCustomPostType: true
};

type Data = SourceData<BaseData, MyCustomPostTypeData | MyOtherCustomPostTypeData>;

interface Theme extends Package {
  state: {
   source?: Merge<
      WpSource["state"]["source"],
      {
        get: Derived<Theme, (link: string) => Data>;
        data: Record<string, Data>;
      }
  }
};

Here is a small playground with the concept to play.

NOTE: I got rid of the Merge function because on the playground didn’t seem necessary and it simplified the error messages from TypeScript, but I don’t know if that’s something we can do or if Merge is actually needed for some reason I couldn’t see. What I understood is that Merge was removing the shared properties between the base and the data, from the base type, and them doing the union. I can’t understand whats the difference between that and doing just the union that overrides the shared properties.

That solution looks fine to me :+1:

Could you do this? (extend PostTypeData instead of BaseData)

type MyCustomPostTypeData = PostTypeData & {
  type: 'my-custom-post-type',
  isMyCustomPostType: true
}

I think you need to do

type MyCustomPostTypeData = PostTypeData<BaseData> & {
  type: 'my-custom-post-type',
  isMyCustomPostType: true
}

I went back to https://www.typescriptlang.org/docs/handbook/advanced-types.html#type-guards-and-differentiating-types and I think there may be a way to not need to define all the booleans in false in a BaseData type by using type guards functions like this:

function isFish(pet: Fish | Bird): pet is Fish {
    return (pet as Fish).swim !== undefined;
}

I assumed that these functions didn’t had any advantage over a boolean like pet.isFish, but I was wrong. They have, because inside the function you can do a manual cast that it’d be ugly to do outside:

(pet as Fish).swim

I’ve done a small example in the TS playground and seems to be working fine.

This is the key:

function isTaxonomy(data: Data): data is (Taxonomy) {
  return (data as Taxonomy).isTaxonomy === true;
}

I guess we could leave data as it is for JavaScript:

if (data.isTaxonomy) {
  // ...
}

And expose a slightly different API for TypeScript:

import { isTaxonomy } from "@frontity/source";

if (isTaxonomy(data)) {
  // ...
}

People extending Data would have to create their own type guard functions:

// packages/some-package/types.ts
import { Package } from "frontity/types";
import { Data } from "@frontity/source";

// Create your new type.
type NewThing = {
  isNewThing: true
}

// Add it to the possibilities already existing in source.
type MyData = Data | NewThing;

// Create your type guard function
export function isNewThing(data: Data): data is NewThing {
  return (data as NewThing).isNewThing === true;
}

export interface SomePackage extends Package {
  state: {
    source?: {
      get: Derived<SomePackage, (link: string) => MyData>;
    }
  }
  //...
}

Then, import your type guard function in your code:

// packages/some-package/src/components/index.ts
import { isNewThing } from "../../types";
import { isArchive } from "@frontity/source";

const MyTheme = ({ state }) => {
  const data = state.source.get(state.router.link);
  if (isNewThing(data)) {
    // data: NewThing !!
  } else if (isArchive(data)) {
    // data: ArchiveData !!
  }
};


@mmczaplinski is going to explore if this can be solved with classes/interfaces inheritance. Let’s wait until we see what he discovers!

1 Like

I like this

I’ve spent a moment playing with an implementation based on classes

I’m not actually 100% sure if this is a valid approach. I think that the problem with it is that we would have to explicitly create class instances for taxonomy, category, etc. in each handler.

I will leave this here in case we want to revisit the implementation, but I’m in favour of Luis’s implementation! :slight_smile:

I didn’t know that this was possible, but it is:

interface TaxonomySearch extends Taxonomy, Search { }

So I have modified the playground example to work with interfaces and inheritance and it works great!

You can do this:

interface Taxonomy {
  isTaxonomy: true;
  taxonomy: string;
}

interface Search {
  isSearch: true;
  searchQuery: string;
}

interface TaxonomySearch extends Taxonomy, Search { }

interface Category extends Taxonomy {
  isCategory: true;
};

interface CategorySearch extends Category, Search { }

type Data = Taxonomy | Search | TaxonomySearch | Category | CategorySearch ...

create type guard functions for each interface:

function isTaxonomy(data: Data): data is (Taxonomy) {
  return (data as (Taxonomy)).isTaxonomy === true;
}

function isSearch(data: Data): data is (Search) {
  return (data as (Search)).isSearch === true;
}

function isCategory(data: Data): data is (Category) {
  return (data as (Category)).isCategory === true;
}

and everything works fine:

const someData = data["some-key"];

if (isTaxonomy(someData)) {
  someData.taxonomy = "";
}

if (isSearch(someData)) {
  someData.searchQuery = "";
}

if (isTaxonomy(someData) && isSearch(someData)) {
  someData.taxonomy = "";
  someData.searchQuery = "";
}

People extending Data could also use inheritance when necessary:

interface NewThing extends Taxonomy {
  isNewThing: true
}

I’m going to try @mmczaplinski’s approach with classes and instanceOf.

It seems like this works well:

class Taxonomy {
  isTaxonomy: true = true;
  taxonomy: string = "";
}

class Category extends Taxonomy {
  isCategory: true = true;
};

if (someData instanceof Taxonomy) {
  someData.taxonomy = "";
}

and requires less code.

But this doesn’t work:

class Search {
  isSearch: true = true;
  searchQuery: string = "";
}

// class cannot extend two classes
class TaxonomySearch extends Taxonomy, Search { }

Interfaces work:

interface TaxonomySearch extends Taxonomy, Search { }

but you cannot use interfaceOf with them:

// instanceof cannot be used with types/interfaces
if (someData instanceof TaxonomySearch) {
  someData.taxonomy = "";
  someData.searchQuery = "";
}

but… for some reason, you can join two different types at runtime.

For example, declaring Data like this:

class Taxonomy {
  isTaxonomy: true = true;
  taxonomy: string = "";
}

class Search {
  isSearch: true = true;
  searchQuery: string = "";
}

type Data = Taxonomy | Search;

const data: Record<string, Data> = {};

You can do instanceOf of both Taxonomy and Search at the same time, even though a TaxonomySearch type doesn’t exist.

// This works fine.
if (someData instanceof Taxonomy && someData instanceof Search) {
  someData.taxonomy = "";
  someData.searchQuery = "";
}

I would never have predicted that, as the merge between Taxonomy and Search is not present in Data. I would have expected to be one or the other.

This is the playground with the working example.

Let’s try more things.

Always-present props

I’ve tried adding always-present props, like isReady or isFetching and it looks like, as long as every member of Data extends from the base type (directly or indirectly), it works without a type guard:

class BaseData {
  isReady: boolean;
  isFetching: boolean;
}

class Taxonomy extends BaseData {
  isTaxonomy: true;
  taxonomy: string;
}

class Search extends BaseData {
  isSearch: true;
  searchQuery: string;
}

class Category extends Taxonomy {
  isCategory: true;
};

type Data = Taxonomy | Search | Category;

// This works fine without type guards:
someData.isFetching = true;

Mix between different types

If we don’t need to add things like TaxonomySearch to Data because it’s enough with separate Taxonomy and Search, then let’s see if we can still mix two types in a single check:

It looks like it can be done with the type guard functions implementation:

type Data = Taxonomy | Search;

function isTaxonomySearch(data: Data): data is Taxonomy & Search {
  const d = data as Taxonomy & Search;
  return d.isTaxonomy === true && d.isSearch === true;
}

if (isTaxonomySearch(someData)) {
  someData.taxonomy = "";
  someData.searchQuery = "";
}

For classes, it doesn’t work with a single instanceOf check because it complains that "taxonomy" is not present on type Search. This is what I would have expected:

// This doesn't work
if (someData instanceof Taxonomy && Search) {
  someData.taxonomy = ""; // taxonomy doesn't exist on type Search
  someData.searchQuery = "";
}

But, surprisingly, it works with two checks or a type guard function:

if (someData instanceof Taxonomy && someData instanceof Search) {
  someData.taxonomy = "";
  someData.searchQuery = "";
}

function isTaxonomySearch(data: Data): data is Taxonomy & Search {
  return data instanceof Taxonomy && data instanceof Search;
}

if (isTaxonomySearch(someData)) {
  someData.taxonomy = "";
  someData.searchQuery = "";
}

Overwrite a property type

Let’s see what happens if we try to extend from a type, but overwrite one property.

You cannot use an interface to extend a type that is not compatible:

// This doesn't work
interface TaxonomyById extends Taxonomy {
  isTaxonomyById: true;
  taxonomy: number;
}

You can use types to merge them:

type TaxonomyById = Taxonomy & {
  isTaxonomyById: true;
  taxonomy: number;
}

But then, you cannot use the property taxonomy anymore, because in the merged type TaxonomyById it gets assigned to the type never:

function isTaxonomyById(data: Data): data is TaxonomyById {
  return (data as TaxonomyById).isTaxonomyById === true;
}

// This doesn't work
if (isTaxonomyById(someData)) {
  someData.taxonomy = 123;
}

There is a workaround though, using Omit while extending:

interface TaxonomyById extends Omit<Taxonomy, "taxonomy"> {
  taxonomy: number;
}

// This works now and taxonomy type is number
if (isTaxonomyById(someData)) {
  someData.taxonomy = 123;
}

Adn if you do this, it goes back to never:

// This doesn't work
if (isTaxonomy(someData) && isTaxonomyById(someData)) {
  someData.taxonomy = 123;
}

So I guess the workaround is valid.

For classes, it looks like you have to first declare an interface and then use implements to attach the type, like this:

interface TaxonomyById extends Omit<Taxonomy, "taxonomy"> {}; 

class TaxonomyById implements TaxonomyById {
  taxonomy: number;
}

and then interfaceOf works fine:

if (someData instanceof TaxonomyById) {
  someData.taxonomy = 123;
}

I’ve just realized two more things while playing with this.

1. implements can extend from an interface that extends two classes

You cannot extend from two different classes:

class TaxonomySearch extends Taxonomy, Search {}

but you can create a interface and implement it:

interface TaxonomySearch extends Taxonomy, Search { }
class TaxonomySearch implements TaxonomySearch { }

and this works:

if (someData instanceof TaxonomySearch) {
  someData.taxonomy = "";
  someData.searchQuery = "";
}

2. instanceOf doesn’t require a union

This code doesn’t produce any TypeScript errors:

class First {
  isFirst: true;
  first: string;
}

class Second {
  isSecond: true;
  second: string;
}

const data: First = {
  isFirst: true,
  first: ""
}

if (data instanceof Second) {
  data.first = "";
  data.second = "";
}

This may mean that:

  • Package creators don’t need to pass anything to extend Data.
  • We don’t need a union of types in Data, we can use BaseData for state.source.data.

This is only true for classes because with interfaces you need the union to create the type guard functions:

interface First {
  isFirst: true;
  first: string;
}

interface Second {
  isSecond: true;
  second: string;
}

const data: First = {
  isFirst: true,
  first: ""
}

// You need the union for the data argument
function isSecond(data: First | Second): data is Second {
  return (data as Second).isSecond === true;
}

if (isSecond(data)) {
  data.first = "";
  data.second = "";
}

At this point I’m more inclined to @mmczaplinski’s approach of classes and instanceOf.

People using TypeScript would have to do this:

import { Taxonomy } from "@frontity/source/types";

if (data instanceOf Taxonomy) {
  // ...
}

People extending Data would have to do this:

// packages/some-package/types.ts
import { Package } from "frontity/types";
import Source, { BaseData, Taxonomy } from "@frontity/source/types";

// Just create a class.
export class NewThing extends BaseData {
  isNewThing: true
}

// Or it can extend other types.
export class NewThing extends Taxonomy {
  isNewThing: true
}

export interface SomePackage extends Package {
  state: {
    // No need for anything special here:
    source?: Source["state"]["source"]
  }
  //...
}
// packages/some-package/src/components/index.ts
import { NewThing } from "../../types";
import { Archive } from "@frontity/source/types";

const MyTheme = ({ state }) => {
  const data = state.source.get(state.router.link);
  if (data instanceOf NewThing) {
    // data: NewThing !!
  } else if (data instanceOf Archive) {
    // data: ArchiveData !!
  }
};

In our side:

state.source.data could be simply an array of BaseData:

  state: {
    source: {
      data: Record<string, BaseData>;

and BaseData could have only the types injected by source on all data objects:

interface BaseData {
  isReady: boolean;
  isFetching: boolean;
  link: string;
  // ...
}

Those would be the only ones accessible without an interfaceOf check:

if (data.isReady) {
  const link = data.link;
  // ...
}

For Source v2 handlers I was thinking about returning the data object (or an array of data objects), instead of assigning it yourself:

import { Handler, Taxonomy, BaseData } from "@frontity/source/types";

type TaxonomyBase = Omit<Taxonomy, keyof BaseData>;

const handler: Handler = async ({ link }) => {
  const response = await fetch(link).then(r => r.json());
  const { items, entities } = normalize(response);
  const data: TaxonomyBase = {
    isTaxonomy: true;
    items,
    taxonomy: response.taxonomy,
    id: response.id
  }
  return { data, entities };
};

So, if the Handler type is permissive enough, we won’t need the union here as well.

The only problem would be that you have to use Omit to get rid of the properties of BaseData.

Basically, we either use Omit like this:

export class Base {
  isReady: boolean;
}

export class Taxonomy extends Base {
  isTaxonomy: true;
}

type TaxonomyBase = Omit<Taxonomy, keyof Base>;

const tax: TaxonomyBase = {
  isTaxonomy: true
}

Or we export both classes with different names:

class Base {
  isReady: boolean;
}

export class Taxonomy {
  isTaxonomy: true;
}

interface TaxonomyData extends Taxonomy, Base { }
export class TaxonomyData implements TaxonomyData { }

const tax: Taxonomy = {
  isTaxonomy: true
}

I guess the first one is fine because if not it would require a lot of almost duplicate exports.

Is it this a TypeScript thing? I mean, when you are using instanceof (which is JavaScript) to check if something is Taxonomy, that will search Taxonomy in the prototype chain, right? So, the data object needs to be created using new Taxonomy(). If not, instanceof will return false.

I mean, you cannot use classes as types here, you need to create instances of classes.

Or am I missing something?


EDIT: taking the example @luisherranz shared, I think the instanceof assertions would never be true

data["category"] = {
  ...data["other-thing"],
  isTaxonomy: true,
  isCategory: true,
};

// All of these would return `false`
if (someData instanceof Taxonomy) {}
if (someData instanceof Search) {}
if (someData instanceof Taxonomy && someData instanceof Search) {}

unless you do something like

data["category"] = new Category();

// This would be `true`
if (someData instanceof Taxonomy) {}

Well, if we use classes and inheritance, I guess we can implement BaseData's constructor in a way that the properties passed as argument are merged properly and so there’s no need to use Omit.

Then, you could do something like this:

import { Handler, Taxonomy } from "@frontity/source/types";

const handler: Handler = async ({ link }) => {
  const response = await fetch(link).then(r => r.json());
  const { items, entities } = normalize(response);
  // data.isTaxonomy would be added by default,
  // as well as other inherited props
  const data = new Taxonomy({
    items,
    taxonomy: response.taxonomy,
    id: response.id
  });
  return { data, entities };
};

In the case you want to instantiate something more specific, you could do it as well without passing the inherited props to the constructor.

import { Handler, Category } from "@frontity/source/types";

const handler: Handler = async ({ link }) => {
  const response = await fetch(link).then(r => r.json());
  const { items, entities } = normalize(response);
  // data.taxonomy would be added by default,
  // as well as other inherited props
  const data = new Category({
    items,
    id: response.id
  });
  return { data, entities };
};

Anyway, we need to check if it’s possible to do this with TypeScript (I guess so).


EDIT: I did a quick test and it think it’s not possible (at least in a simple way), e.g. this code:

class Data {
  isReady: boolean = false;
  isFetching: boolean = false;

  constructor(props: any) {
    Object.assign(this, props);
  }
}

class Taxonomy extends Data {
  isTaxonomy: true = true;
  taxonomy: string = "";
}

is compiled into

"use strict";
class Data {
    constructor(props) {
        this.isReady = false;
        this.isFetching = false;
        Object.assign(this, props);
    }
}
class Taxonomy extends Data {
    constructor() {
        super(...arguments);
        this.isTaxonomy = true;
        this.taxonomy = "";
    }
}

The default Taxonomy values would overwrite those values passed to props, so you would have to extend the constructor in every subclass to set the correct values…

class Data {
  isReady: boolean = false;
  isFetching: boolean = false;

  constructor(props: any) {
    Object.assign(this, props);
  }
}

class Taxonomy extends Data {
  isTaxonomy: true = true;
  taxonomy: string = "";

  constructor(props: any) {
    super(props);
    this.taxonomy = props.taxonomy;
  }
}

That would work, but… :man_shrugging:

Not really, you can use the class as a type for an object with the same props:

class Base {
  isReady = false;
  isFetching = false;
}

const base: Base = {
  isReady: true,
  isFetching: false
}

And you can do an instanceOf of anything, no matter the type:

class Base {
  isReady = false;
  isFetching = false;
}

const noType = {}

if (noType instanceof Base) {
  noType.isFetching = true;
}

Playground for this here.

Good idea, we can do that.

I think it’s not that ugly. And for handlers, it can be great to use new Category({ ... }).

I think we can avoid having any in props and this is not that complex (I guess):

class Base {
  isReady = false;
  isFetching = false;
}

class Taxonomy extends Base {
  isTaxonomy: true = true;
  taxonomy: string;
  id: number;

  constructor(props: { taxonomy: Taxonomy["taxonomy"]; id: Taxonomy["id"]}) {
    super();
    Object.assign(this, props);
  }
}

class Category extends Taxonomy {
  isCategory: true = true;
  taxonomy: "category";

  constructor(props: { id: Taxonomy["id"] }) {
    super({ taxonomy: "category", id: props.id })
  }
}

const tax = new Taxonomy({
  taxonomy: "some-taxonomy",
  id: 123
});

const cat = new Category({
  id: 123
});

This is the playground.

We can also use Pick for the constructor type, which is the opposite of Omit:

class Taxonomy extends Base {
  isTaxonomy: true = true;
  taxonomy: string;
  id: number;

  constructor({ taxonomy, id }: Pick<Taxonomy, "taxonomy" | "id">) {
    super();
    this.taxonomy = taxonomy;
    this.id = id;
  }
}

class Category extends Taxonomy {
  isCategory: true = true;
  taxonomy: "category";

  constructor({ id }: Pick<Category, "id">) {
    super({ taxonomy: "category", id })
  }
}

I think that TypeScript could work in that case, but if you run later

if (base instanceof Base) {
  ...
}

any code inside the if would never be reached, because Typescript’s instanceof is compiled to JavaScript’s instanceof, and that would check if base (which is actually compiled as a plain object) is an instance of Base, i.e. it would look for Base in its prototype chain.

That would fail, right? :thinking:

You are absolutely right :sweat: :persevere: :persevere: :persevere:

I feel stupid :laughing:

We’re back to functional type guards then :+1:

To summarize, people using TypeScript would have to do this:

import { isTaxonomy } from "@frontity/source";

if (isTaxonomy(data)) {
  // ...
}

People extending Data would have to do this:

// packages/some-package/types.ts
import { Package } from "frontity/types";
import Source, { BaseData, Taxonomy, Data } from "@frontity/source/types";

// Just create a class.
export interface NewThing extends BaseData {
  isNewThing: true
}

// Or it can extend other types.
export interface NewThing extends Taxonomy {
  isNewThing: true
}

type ExtendedData = Data | NewThing;

export function isNewThing(data: ExtendedData): data is NewThing {
  return (data as NewThing).isNewThing === true;
}

export interface SomePackage extends Package {
  state: {
    source?: Source<ExtendedData>["state"]["source"]
  }
  //...
}

We can use generics in the Source type to allow for a different Data, like this Source<ExtendedData>;

// packages/some-package/src/components/index.ts
import { isNewThing } from "../../types";
import { isArchive } from "@frontity/source";

const MyTheme = ({ state }) => {
  const data = state.source.get(state.router.link);
  if (isNewThing(data)) {
    // data: NewThing !!
  } else if (isArchive(data)) {
    // data: ArchiveData !!
  }
};

In our side:

state.source.data could be simply an array of BaseData:

interface Source<D = Data> extends Package {
  state: {
    source: {
      data: Record<string, D>;

BaseData would have only the types injected by source on all data objects:

interface BaseData {
  isReady: boolean;
  isFetching: boolean;
  link: string;
  // ...
}

Those would be the only ones accessible without a type guard check:

if (data.isReady) {
  const link = data.link;
  // ...
}

And finally for Source v2 handlers we can use generics too:

import { Handler, Taxonomy } from "@frontity/source/types";
import { ExtendedData, NewThing } from "../../types";

type NewThingBase = Omit<NewThing, keyof Taxonomy>;

const handler: Handler<ExtendedData> = async ({ link }) => {
  const response = await fetch(link).then(r => r.json());
  const { entities } = normalize(response);
  const data: NewThingBase = {
    isNewThing: true;
    // ...
  }
  return { data, entities };
};