24
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 paramsNow 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: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 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:
tsconfig.json
or whatever.d.ts
file.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!
24