DEV Community

Mike Solomon for Meeshkan

Posted on

Extension types in TypeScript

When creating a TypeScript library, you will invariably run into a scenario where someone wants to extend it. In JavaScript land, they can do this with the following hack:

const yourLibrary = require('your-library');
const myExtension = require('./my-extension');
yourLibrary.yourObject.myExtension = myExtension

In TypeScript, this type of thing is generally frowned upon because the type system will not (easily) allow monkey patching. The “classic” workaround is using a cast to the any type, which effectively de-TypeScript-ifies TypeScript.

import { yourObject } from 'your-library';
import myExtension from 'my-extension';
(<any>yourObject).myExtension = myExtension

The other problem with this pattern is that it myExtension is not referenceable on yourObject, which requires a cast on every access as well.

const result = (<any>yourObject).myExtension.myFunction();

At this point, we have lost the type-safety of myExtension. So the TypeScript compiler will no longer check that myFunction actually exists on myExtension, let alone what the result of myFunction() is. Too much of this pattern will render your TypeScript project un-typeable, at which point JavaScript would have been a better option.

What to do?

One solution is just to make a pull request to the original library to have your extension incorporated into its official package. While this may be a good idea in some scenarios, in most cases extensions are too niche, too broken or too big to be merged into a project. On top of that, pull requests often take a long time to go through review and incorporation into a new release.
Another solution, which is what this article advocates, is building libraries with type-safe extensions as first-class citizens. I realize that this won’t solve your problem here-and-now, but if you are a library author, it will give other developers an easy way to extend your work without necessarily touching the core package. If you are a consumer of a library, a pull-request giving it extensional properties is usually a much easier ask than extending the library with your specific feature.

Two types of extensions

The most common types of extensions developers need are intersection extensions and union extensions. Intersection extensions say “Hey, your objects are awesome, but they would be even more awesome if they did thing X.” Union extensions say “Hey, your objects are awesome, but you’re missing a few that I’d need for scenario Y.” Intersections and unions are part of TypeScript’s core language — the intersection & and union | operator are a basic way to build composite types. What I’m advocating is leveraging these operators to supercharge your libraries with extensionality.

Intersection extensions

Intersection extensions can be achieved with a generic type (let’s call it U) that is passed down through your objects and intersected with primitive objects via the & operator.

Let’s imagine that your library contains the following two types.

type Person = {
  name: string;
  address?: Address;
  friends?: Person[];
}
type Address = {
  city: string;
  country: string;
}

Intersection extensions add an intersection to all relevant types.

type Person<U> = {
  name: string;
  address?: Address<U>;
  friends?: Person<U>[];
} & U;
type Address<U> = {
  city: string;
  country: string;
} & U;

For example, if we want to add an optional id to all types, it becomes a simple operation.

const me: Person<{id?: number}> = {
  name: 'Mike',
  address: {
    id: 5,
    city: 'Helsinki',
    country: 'Finland'
  },
  friends: [{ name: 'Marie', id: 101 }]
}

Even better, we now have a type-safe accessor for id, so the following function will pass the TypeScript compiler

const hasId = (p: Person<{id?: number}>) => typeof p.id === 'number';

Union extensions

Let’s imagine a different scenario — we are creating types for JSON objects.

type JSONPrimitive = number | boolean | string | null;
type JSONValue = JSONPrimitive | JSONArray | JSONObject;
type JSONObject = { [k: string]: JSONValue; };
interface JSONArray extends Array<JSONValue> {}

Let’s say that we would like JavaScript Date objects to also be admitted as JSON. Union extensions, which I’ll represent with the letter T, give us a clean way to do this.

type JSONPrimitive<T> = number | boolean | string | null | T;
type JSONValue<T> = JSONPrimitive<T> | JSONArray<T> | JSONObject<T>;
type JSONObject<T> = { [k: string]: JSONValue<T>; };
interface JSONArray<T> extends Array<JSONValue<T>> {}

Now, we can put Date objects all over our JSON and the TypeScript compiler will not complain.

const jsonWithDates: JSONValue<Date> = {
  foo: 1,
  bar: new Date(),
  baz: [true, 'hello', 42, new Date()]
}

Runtime validation

If you are using a runtime type validator like io-ts, the patterns are quite similar. For intersections, we can use the intersection function from io-ts.

import * as t from 'io-ts';
const PersonValidator = <U>(u: t.TypeOf<U, U>) = t.recursion(
  'Person',
  t.intersection([
    t.type({name: t.string}),
    t.partial({
      address: AddressValidator(u),
      friends: t.array(PersonValidator(u))
    }),
    u
]));
const AddressValidator = <U>(u: t.TypeOf<U, U>) = 
  t.intersection([
    t.type({city: t.string, country: t.string}),
    u    
  ]);

The same type of pattern can be used for union types — just pass the a validator to t.union instead of t.intersection where needed.

Show me the code!

This is the strategy I used to build json-schema-strictly-typed, which creates a typed version of JSON Schema that is extensible with both intersection and union extensions. This way, people can add arbitrary extensions to objects in the Schema (intersection) and arbitrary new Schema primitives (union).

From this level of generic-ness (genericity?, generickitude?), it is easy to export helper objects for “base” cases. The base case of an intersection extension is simply a type from which all your objects already extend. In the example above, Person<{}> and Address<{}> would be this, as intersecting with {} is a no-op. For union types, a default extension could be extending the library with a type that already exists in the union. So, for example, JSONSchemaValue<string> is a no-op.

I’m looking forward to seeing if this pattern catches on and if the community can come up with tools that help maintain and author TypeScript libraries with extensibility in mind!

Latest comments (1)

Collapse
 
klvenky profile image
Venkatesh KL

I do that a lot and I like it. Your explanation was wonderful