DEV Community

Alexandrovich Dmitriy
Alexandrovich Dmitriy

Posted on

Configurable typing of NPM packages. Typing may be stricter than you think

In this article, I'd like to show how you can create NPM packages so that the user of your library (the developer) can configure the typing of your package. And I will also describe why and to whom it may be interesting.


Why do we need configurable typing?

Configuration of the executable logic of NPM packages is not something new. For example, in Axios, you can globally change the default components of requests, as well as globally change the logic of the requests themselves using the interceptors. And MobX, for example, provides the configure function, with which you can disable some checks or slightly change the default logic.

import axios from 'axios';
import { configure } from 'mobx';

// Example of global configuration of requests in Axios
axios.interceptors.response.use(
  // Due to the fact that this function returns not response,
  //  but response.data, the structure of the function result 
  //  becomes different. 
  (response) => response.data,
  // And thanks to this callback, we can output messages to 
  //  the console at certain server response statuses.
  (error) => {
    if (error.response?.status === 401) {
      console.error('User is not authorized');
    }
    if (error.response?.status === 404) {
      console.error('Not found');
    }
    return Promise.reject(error);
  }
);

// Example of configuration in MobX
configure({
  // This setting removes the verification of the correctness 
  //  of updating the observed fields
  enforceActions: 'never',
  // And this one allows you to use MobX without using the
  //  `Proxy` object
  useProxies: 'never',
});
Enter fullscreen mode Exit fullscreen mode

But we are here to talk about configurable typing. What is it in general?

In the example above with axios, the structure of the result of the request function was changed. But TypeScript didn't notice this change. Accordingly, errors or other difficulties may occur at the compilation stage if you configure your requests in this way. However, if you had the opportunity to configure the typing of the function result, such problems would not arise.

Another example is decorators. If you follow TypeScript updates, you know that in its major fifth version there is a new implementation of decorators - proposal, a MicroSoft article. But if not, I'll tell you briefly. There were decorators in TypeScript before, but in a different implementation. And to use them, you had to configure tsconfig.json.

// file.ts
declare const oldExperimentalDecorator: PropertyDecorator;

export class Class {
  @oldExperimentalDecorator
  public property: number = 1;
}

// tsconfig.json
{
  "compilerOptions": {
    // ...
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
  },
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Now the “old” decorators are still available. However, if experimentalDecorators: true is not specified in tsconfig in the fifth version, TypeScript will compile decorators in a new way. And will use the new typing.

declare const oldExperimentalDecorator: PropertyDecorator;
declare const newDecorator: <This, Value>(_: unknown, context: ClassFieldDecoratorContext<This, Value>) => void;

export class Class {
  // If `experimentalDecorators` in tsconfig is false, or not set
  // there will be a type error
  @oldExperimentalDecorator
  public property: number = 1;


  // If `experimentalDecorators` in tsconfig is true there will
  // be a type error
  @newDecorator
  public property1: number = 1;
}
Enter fullscreen mode Exit fullscreen mode

And now imagine. You are creating an NPM package, and you want to make it possible to use decorators both in the old implementation and in the modern one. You can write logic so that your decorators work in both cases, but you will not be able to write typing so that TypeScript correctly analyzes your code in both cases. You cannot know in advance whether the developer of the final product has enabled the experimentalDecorators option.

And then configurable typing comes into play. You can give the developer the opportunity to say which typing he wants to use in his project. In the example above, the developer could say that he wants to use legacy or modern decorators.


Preparing for the dive

Before I describe how such a configuration can be implemented, it will not be superfluous for some readers to remind about some of the features of TypeScript.

Firstly, in TypeScript it is possible to “merge” interfaces. Thanks to this, you can extend the typing set using the interface keyword. To do this, you just have to declare an interface with exactly the same name as the interface you want to extend.

interface Interface {
  prop1: number;
}

interface Interface {
  prop2: number;
}

declare const value: Interface;

// We can use both prop1 and prop2 since our interfaces were merged
console.log(value.prop1, value.prop2);
Enter fullscreen mode Exit fullscreen mode

Secondly, when you download an NPM package, its name - what is used in package.json - becomes the name of the module. Yes, if you didn't know, the name for the module is taken from this file, so you can, for example, download the axios library, but call it whatever you want.

// package.json
{
  "dependencies": {
    "@types/react": "^16.14.43",
    "@types/react2": "npm:@types/react@18",
    "axios": "^1.4.0",
    "axixixios": "npm:axios@1.4.0",
    "react": "^16.14.0",
    "react2": "npm:react@18"
  }
}

// file.ts
// And now we can use modules ‘react’, ‘react2’, ‘axios’ и ‘axixixios’
import axios from 'axios';
import axixixios from 'axixixios';
import React from 'react';
import React2 from 'react2';
Enter fullscreen mode Exit fullscreen mode

And thirdly, you can change the typing of modules in your project. To do this, you can use the declare keyword. Using the export keyword, you can type a new exported entity. But what interests us more is that when declaring a module, you can also declare an interface. And if this module exports an interface with the same name, they will be merged.

import { FunctionComponent } from 'react';

declare module 'react' {
  interface FunctionComponent {
    newStaticProperty: number;
  }
}

declare const Component: FunctionComponent;

// There's no types error here, interfaces were merged
console.log(Component.newStaticProperty);
Enter fullscreen mode Exit fullscreen mode

How do I set up configurable typing?

The examples below show typing within a single project for simplification. If you are interested in looking at a full-fledged example with the extension of the typing of individual NPM packages, you can visit my repository.

The knowledge described above is already enough so that we can extend the interface described in the external module. In the React example, we added a new static field to the typing of functional components.

However, using this mechanism, you can fully shift the responsibility for typing entities to the developer of the final product.

// file1.ts
export interface TypesConfiguration {}

// Type must be set in the final product. | undefined is not necessary
export const value: (TypesConfiguration extends {
  value: unknown
} ? TypesConfiguration[value] : never) | undefined = undefined;

// =====================

// file2.ts
import { value } from './file1';
import { expectType } from 'ts-expect';

declare module './file1' {
  interface TypesConfiguration {
    value: string;
  }
}
// There's no error. The type is `string | undefined`
expectType<string | undefined>(value);
// Error. The type is `string | undefined`
expectType<number | undefined>(value);
Enter fullscreen mode Exit fullscreen mode

I have added the | undefined typing so that I can export a default undefined value. If I hadn't done this, then the value type would have been strictly the same as what we would have passed to the TypeConfiguration interface.

And you can also use conditional typing. Thanks to it, you can set the fallback to a less strict type for your entity, if a stricter typing has not been declared in the final product. Or you can switch your typing from one state to another as with a flag.

/ file1.ts
export interface TypesConfiguration {}

// Typed by flag. If flag is not used, type is string, otherwise - number. `| undefined` is not necessary
export const valueTypedByFlag: (TypesConfiguration extends {
  useNumber: true
} ? number : string) | undefined = undefined;

// Type must be set in the final product. If the type is not set, a fallback non-strict type is used
export const mapTypedWithFallback: TypesConfiguration extends { mapTypedWithFallback: unknown }
? TypesConfiguration['mapTypedWithFallback']
: Record<string, number> = {};

// =====================

// file2.ts
import { value, map } from './file1';
import { expectType } from 'ts-expect';

declare module './file1' {
  interface TypesConfiguration {
    // If useNumber: true is not passed, the type of `value`
    //  will be string
    useNumber: true;

    // If `map` is not passed, we can use any key in the map.
    //  But now only `key1` and `key2` are accessible
    mapTypedWithFallback: {
      key1: 1,
      key2: 2,
    }
  }
}


// No error since useNumber: true is used
expectType<number | undefiend>(value);

// No error
console.log(map, map.key1, map.key2);

// Error if `mapTypedWithFallback` is passed
console.log(map.randomKey);
Enter fullscreen mode Exit fullscreen mode

But when it starts to be useful?

The approach to such typing will be useful mainly where the strictness of typing is important. For example, in large projects with several development teams. But to make it more clear to you, let me throw in some typical examples that show what real tasks configurable typing can solve.

Example with decorators

Let's go back to the example with decorators. As with the useNumber flag, we can tell the developer of the final library that he can add a flag to the TypesConfiguration interface that will be responsible for switching the type of decorators.

// file1.ts
export interface TypesConfiguration {}

export const decorator: TypesConfiguration extends { experimentalDecorators: true }
  ? PropertyDecorator
  : <This, Value extends number>(_: unknown, context: ClassFieldDecoratorContext<This, Value>) => void;

// file2.ts
import { decorator } from './file1';

declare module './file1' {
  interface TypesConfiguration {
    // Must be set to true only if it is set to true in the tsconfig file.
    experimentalDecorators: true;
  }
}

class Class {
  @decorator
  property: number;
}
Enter fullscreen mode Exit fullscreen mode

Thus, the developer of the final product will be free to decide for himself which typing he wants to use on decorators. If he needs exclusively outdated decorators, he will need to switch the experimentalDecorators flag to true in tsconfig, and then specify experimentalDecorators: true in TypesConfiguration. Otherwise, you will not need to register anything in tsconfig and TypesСonfiguration.

Registration/Usage example

Another example that I notice relatively often in NPM packages is an approach with registration and usage of a certain entity by key. For example, I'll take the library Tyringe.

import { container } from 'tsyringe';

class A { prop1: number }
class B { prop2: string }

// The registration of an entity
container.register('ClassA', A);
container.register('ClassB', B);

// The usage. Types are applied by generic
const a: A = container.resolve('ClassA');
const b = container.resolve<B>('ClassB');
Enter fullscreen mode Exit fullscreen mode

Some key and value are passed to the register function. And in the resolve function, you can retrieve a value using the key. In TSyringe, you can type the object received in the container.resolve function using a generic - which I did in the example above.

But this approach is not very safe. First, in the resolve function, you can pass a key that does not exist. Secondly, Syringe does not control typing in any way when using this function. Therefore when using a generic of the wrong type, no error will occur. It's not even close to be called strict.

// Typed as A, a valid case
const a: A = container.resolve('ClassA');
// Typed as A, although it is B
const b: A = container.resolve('ClassB');
// Typed as B, although it doesn’t exist
const c: B = container.resolve('Unknown token');
Enter fullscreen mode Exit fullscreen mode

However, strictness can still be added. We can definitely say that by a certain key we should always receive an entity of a certain type.

// file1.ts
export interface TypesConfiguration {}

type KeyToClassMap = TypesConfiguration extends { registry: unknown }
  ? TypesConfiguration['registry']
  : Record<string, any>;

type Constructable<T> = { new (...args: unknown[]): T };

const map: KeyToClassMap = {}

export type TRegister = TypesConfiguration extends { registry: unknown }
  // @ts-ignore
  ? <Key extends keyof TypesConfiguration['registry']>(key: Key, Class: Constructable<TypesConfiguration['registry'][Key]>) => void
  : (key: string, Class: Constructable<any>) => void;

export const register: TRegister = (key, Class) => {
  map[key] = new Class();
};

export type TResolve = TypesConfiguration extends { registry: unknown }
  // @ts-ignore
  ? <T extends keyof TypesConfiguration['registry']>(key: T) => TypesConfiguration['registry'][T]
  : <T>(key: string) => T;

export const resolve: TResolve = (key) => map[key];

// =====================

// file2.ts
import { resolve, register } from './file1';

declare module './file1' {
  interface TypesConfiguration {
    // If we define registry here, our types become much stricter
    registry: {
      ClassA: A,
      ClassB: B,
    }
  }
}

class A { prop1: number }
class B { prop2: string }


// Can register only `A`
register('ClassA', A);
// Can register only `B`
register('ClassB', B);


// Can return only type A
const a = resolve('ClassA');
// Can return only B. Which is why `: A` type will cause an error
const b: A = resolve('ClassB');
// Unknown key will cause an error
const c = resolve('Unknown token');
Enter fullscreen mode Exit fullscreen mode

In this case, our typing is as strict as possible. The developer will not be able to use the register function with a new key until they extend TypesConfiguration['registry']. And they will also not be able to use the resolve function with a key that does not exist, or incorrectly type the object returned in the function.


Do I need to use configurable typing in your package?

Depends on what problems it solves. If it already provides strict typing, if a scenario for changing the structure of your entities is unacceptable there, and if you don't export decorators, than no, you do not need it. But in the opposite case, you can estimate how much this approach can be useful to you.

If you still want to look in this direction, then I hasten to assure you that switching typing by flag and passing stricter typing will help you in implementing configurable typing almost always. At the very beginning, I gave an example of changing the structure of the request response in axios, and it is also quite possible to provide an opportunity for such a configuration using these techniques.


The end?

This work stemmed from my pet project, in which I was just working with decorators. I wanted to make them so that they work everywhere, but I met the problem with typing. And while I was studying the work of TypeScript to solve it, I didn't find a good solution. However, as it turned out, sometimes it's enough to look at the knowledge that you already possess from a different angle to solve your problem.

I hope it was interesting for you to read. As part of self-promotion, I will say that I recently wrote an article in which I conducted a benchmark of ECMAScript performance features. If you are interested in reading which compiler generates the most efficient code or which browser is more productive than others, you are welcome.

Once again, I will attach a link to the repository with full-fledged examples of type configuration in RPM packages with unit tests for types.

Here are my social links: twitter, mastodon, linkedin. And that's it. Bye.

Top comments (0)