19
How to add custom types to a javascript library
Few weeks ago, I started contributing to an open source library called Teaful, a Tiny, EAsy, and powerFUL for React state management, with ambitious roadmap. Now Teaful
reached more than 500 GitHub ⭐️ Stars, the library and his community are growing fast.
That means issues and pull requests are growing as well, and soon we realized that we need to improve dev-experience and provide tools for that reason.
Bear this in mind, implement custom types to allow all the benefits from TypeScript
at Teaful
is a big step on that way.
(Yes, I know, migrate a library to pure ts probably is a better solution, and it's on our roadmap before 1.0.0)
In our case, an auto-generated custom type full of any
was useless. So, we started implementing custom types.
We're using microbundle
, they provide a flag to avoid auto-generate types, --no-generateTypes
. Microbundle, according to docs, generally respect your TypeScript config at tsconfig.json
(you can read more about here), but at this moment we don't need a specific configuration for TypeScript
Then we can inform on package.json
where are our custom types with "types": "folder/index.d.ts"
.
Create a file with extension .d.ts
, generally you'll put this file on dist
folder. Now here you can add your custom types.
Here I'm going to explain how we created custom types specifics for Teaful
and why some decisions were taken, if you're reading this to know how to add custom types to your js library and already know about TypeScript
, feel free to skip this section.
The store
is where Teaful
saves data, is a key-value object (you can have more than one store). Easy to type:
type initialStoreType = Record<string, any>;
So far so good, nothing strange here. We want to store anything, and all keys will be string.
Then things become more complicated. In this article only things about creating types will be explained, so if you want to know more about how to implement Teaful
I strongly recommend visit the README at github.
To create a new value on store is pretty similar to useState
from React
. Let's see an example:
const [username, setUsername] = useStore.username();
Easy right? Ok, so what have we here? useStore
returns an array of two elements (Yes! Like useState!), the element in the store and the function to update it.
The type we need:
type HookReturn<T> = [T, (value: T | ((value: T) => T | undefined | null) ) => void];
If you're not familiar with TS, this could look a little cryptic. We're creating a new type called HookReturn
which gets a generic type we called 'T
' (from Type, but you can use any name).
This type is a tuple(a data structure that is an ordered list of elements with a fixed length, because we aren't going to add more elements for the return of our useStore
), where first element is T
, because we want to return a value with specific type that we don't know at the moment of creating the type, but we want to ensure, for example, that the setter function (the second element on this tuple) will get the same type we are using for the first element as param.
Then, let's pay attention on the second element of our tuple.
(value: T | ((value: T) => T | undefined | null) ) => void
Here, our type is a function that returns nothing ( () => void
), but accepts one param (value: T | ((value: T) => T | undefined | null)
), and this param could be a value of type T
, or a function that get a value of type T
and returns null
, undefined
or a value of type T
((value: T) => T | undefined | null
).
What this means? what are we allowing here with this type? Ok, let's imagine a counter:
const [counter, setCounter] = useStore.counter();
//allowed by T
setCounter(counter+1);
//allowed by ((value: T) => T | undefined | null)
setCounter((counter) => counter*2))
setCounter((counter) => undefined)
setCounter((counter) => null)
Yes, Teaful accepts a function as param on the setter function.
When you create/call a new property with useStore, you call useStore.[newProperty]()
. This accepts two optional params, first for initialValue
, and the second one is for updateValue
(a function to update the store property indicated with the proxy
). The hook looks easy to create here:
type Hook<S> = (
initial?: S,
onAfterUpdate?: afterCallbackType<S>
) => HookReturn<S>;
Both optional, but the second one is a specific function. Type onAfterUpdate
, is a function with two params: store
before and after the changes, both will be same type, extending our initialStore
type.
type afterCallbackType<S extends initialStoreType> = (
param: { store: S; prevStore: S; }
) => void
Finally, our type Hook
will return a tuple [property,setter]
, so indeed, we're going to return our custom type HookReturn
with our generic type. If we create a number, have sense to take care about number type in all places, for the initial value, the returned tuple... etc.
Teaful allows to use it as Hoc (as connect on Redux, code explain it by itself):
const { withStore } = createStore({ count: 0 });
class Counter extends Component {
render() {
const [store, setStore] = this.props.store;
return (
// [...]
);
}
}
// Similar to useStore()
const CounterWithStore = withStore(Counter);
The HOC withStore
wraps a Component
and returns the component with a prop called store. A second parameter for initial value is allowed, and a third one for onAfterUpdate
callback.
type HocFunc<S, R extends React.ComponentClass = React.ComponentClass> = (
component: R,
initial?: S,
onAfterUpdate?: afterCallbackType<S>
) => R;
We need two generic types, one for initial value and onAfterUpdate
(both will use same generic, but onAfterUpdate
will have a specific type, explained later) and the other one for React
component to wrap that would be the same for the return, because we want the same component but with a new prop called store.
Look at the R
type, is extending React.ComponentClass
(type provided by React
). This means that we are taking profit from that type and including it in our generic type called R
.
Why extending component class only and not functional component?
Well, we didn't found a single situation when we wanted to wrap any component that doesn't extend Class with a HOC to get the store.
Ok, third type: onAfterUpdate
. Here we need a function with two params store before and after the changes, both will be same type, extending our initialStore
type. Same as first hook, we reuse same type for all callbacks params
Now we only have to export the a type to use
export type Hoc<S> = { store: HookReturn<S> };
Teaful
provides a helper called getStore
, like useStore but:
- It does not make a subscription. So it is no longer a hook and you can use it as a helper wherever you want.
- It's not possible to register events that are executed after a change.
This means we don't want same as useStore
type, we return the same but we want to ensure we don't accept a second param as callback. Let's create another one:
type HookDry<S> = (initial?: S) => HookReturn<S>;
The return is clear, same as Hook.
Ok, now we have almost all the work done. A custom type is needed for each tool, useStore
, getStore
and withStore
:
type getStoreType<S extends initialStoreType> = {
[key in keyof S]: S[key] extends initialStoreType
? useStoreType<S[key]> & HookDry<S[key]> : HookDry<S[key]>;
};
type useStoreType<S extends initialStoreType> = {
[key in keyof S]: S[key] extends initialStoreType
? useStoreType<S[key]> & Hook<S[key]> : Hook<S[key]>;
};
type withStoreType<S extends initialStoreType> = {
[key in keyof S]: S[key] extends initialStoreType
? withStoreType<S[key]> & HocFunc<S>
: HocFunc<S>;
};
The keyOf
type operator ensures that our property
will exist on store
.
The ternary here looks weird if you're not familiar with Typescript
, is used for conditional-types. The logic shared in three types is, get a generic type (S
, that extends our initialStoreType
), then get a key
that must be on S
(the property should exists on our store).
Finally, this withStoreType<S[key]> & HocFunc<S>
is a Intersection type. According to TypeScript documentation "An intersection type combines multiple types into one". So if S[key]
extends initialStore
, we set the intersection type, if not, the hook/hoc type only.
Last, the function to export from Teaful
, the masterpiece:
function createStore<S extends initialStoreType>(
initial?: S,
afterCallback?: afterCallbackType<S>
): {
getStore: HookDry<S> & getStoreType<S>;
useStore: Hook<S> & useStoreType<S>;
withStore: HocFunc<S> & withStoreType<S>;
};
That's definitely not everything, but there are few steps that you'll face:
- Check how to stop auto-generated types, check if types are generated by the bundler like our case, by
tsconfig.json
or whatever. - Create a custom types on a
d.ts
file. - Indicate to
package.json
the place of that file with property"types"
.
Adding custom types to a javascript library could be difficult at the beginning, but will improve the dev-experience from your users.
And most important, this could be a great opportunity to learn and improve your skills, to start networking with the community or a good way to help other devs.
I hope it was helpful to you, have a super nice day!
19