DEV Community

Cover image for How to add custom types to a javascript library
Danielo Artola for ManoMano Tech Team

Posted on • Edited on

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)

Autocomplete code gif

Index

  1. Avoiding auto generated types
  2. Where to place custom types
  3. Create custom types
  4. Conclusions

Avoiding auto generated types

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".

Where to place custom types

Create a file with extension .d.ts , generally you'll put this file on dist folder. Now here you can add your custom types.

Create 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.

InitialStore

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>;
Enter fullscreen mode Exit fullscreen mode

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.

Hook Return

To create a new value on store is pretty similar to useState from React. Let's see an example:

const [username, setUsername] = useStore.username();
Enter fullscreen mode Exit fullscreen mode

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];
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Yes, Teaful accepts a function as param on the setter function.

Hook type

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>;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Hoc type

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);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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> };
Enter fullscreen mode Exit fullscreen mode

HookDry type

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 useStoretype, 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>;
Enter fullscreen mode Exit fullscreen mode

The return is clear, same as Hook.

Let's type useStore, getStore and withStore

Ok, now we have almost all the work done. A custom type is needed for each tool, useStore, getStoreand 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>;
  };
Enter fullscreen mode Exit fullscreen mode

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 keythat 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.

createStore

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>;
  };
Enter fullscreen mode Exit fullscreen mode

Conclusions

That's definitely not everything, but there are few steps that you'll face:

  1. Check how to stop auto-generated types, check if types are generated by the bundler like our case, by tsconfig.json or whatever.
  2. Create a custom types on a d.ts file.
  3. 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!

Thank to english.with.yama@gmail.com for proofreading the article.

Top comments (3)

Collapse
 
tanierefrancois profile image
François Taniere

Super helpful article! What a great way to think outside the box ;)
Props on you and the team !

Collapse
 
danielart profile image
Danielo Artola

Absolutly, I agree. We were considering that possibility too, is a good option indeed

Collapse
 
aralroca profile image
Aral Roca

Congratulations for your first post in dev.to! Great article