Hey, I have an idea that would not require devs to extend Data
and it seems to work nicely.
Actually, extending Data
has a big problem. Imagine that we extend Data
to MyData
:
type Data = Taxonomy | Post; // The ones you are used to.
type MyData = Data | NewThing; // New data with property `isNewThing`.
export interface SomePackage extends Package {
state: {
source?: {
get: Derived<SomePackage, (link: string) => MyData>;
}
}
//...
}
If you wanted to use the default guard functions that we will have already defined in @frontity/source
they wouldn’t work, because the would expect an argument of type Data
instead of MyData
.
// This is how `isTaxonomy` is supposed to be defined.
// Note that it is using an argument of type `Data`, not `MyData`.
function isTaxonomy(data: Data): data is Taxonomy {
return (data as Taxonomy).isTaxonomy === true;
}
/**
* The next check would return this TypeScript error:
*
* Argument of type 'MyData' is not assignable to parameter of type 'Data'.
* Type 'NewThing' is not assignable to type 'Data'.
* Type 'NewThing' is missing the following properties from type 'Post':
* isPostType, isPost, type, id
*/
const data = state.source.get("/some/link");
if (isTaxonomy(data) { ... } // TypeScript error!
Okay, but what about not using Data
anymore?
Let’s say we define these data types: Taxonomy
and Post
, both extending Base
.
interface Base {
isFetching: boolean;
isReady: boolean;
}
interface Taxonomy extends Base {
isTaxonomy: true;
taxonomy: string;
}
interface Post extends Base {
isPostType: true;
isPost: true;
type: "post";
id: number;
}
Instead of using Data
in the guard functions, we could use Base
.
function isTaxonomy(data: Base): data is Taxonomy {
return (data as Taxonomy).isTaxonomy === true;
}
function isPost(data: Base): data is Post {
return (data as Post).isPost === true;
}
And, for state.source.get()
, we could set the return type as Base
. That type has the common properties of every data object and you could check isReady
or isFetching
. If you want to use another property you will need to use a type guard function to cast the data object in the same way as if it had returned an object of type Data
, so we are not gaining anything using Data
here.
Same with state.source.data
. As long as any object you are adding there extends from Base
, you could add whatever you want.
export interface Source {
state: {
source: {
// Return an object of type `Base`.
get: Derived<Source, (link: string) => Base>;
// You can store here any object that extends `Base`.
data: Record<string, Base>;
}
}
//...
}
Then, if you want to add your own data types, it would be as simple as creating your own interface and its guard function.
// `NewThing` should extend `Base` (or any other type extending `Base`).
interface NewThing extends Base {
isNewThing: true;
}
// The argument is of type `Base`.
function isNewThing(data: Base): data is NewThing {
return (data as NewThing).isNewThing === true;
}
That’s the only thing you would have to do, and this would work out of the box:
// `data` here is of type `Base`.
const data = state.source.get(link);
// You can check things as `isReady` or `isFetching`.
if (!data.isReady) return null;
// The following type guards will work as expected.
if (isTaxonomy(data)) {
data.isTaxonomy;
data.taxonomy;
} else if (isPost(data)) {
data.id;
data.isPost;
data.isPostType;
} else if (isNewThing(data)) {
data.isNewThing;
}
@dev-team, I would like to hear your feedback
EDIT: I can’t think of any drawback for this. Let me know if you see any problem.