β€’

tech

What's behind a type safe API

Type safety is ideal in user-land. But internal library code cannot always have the privilege of full type safety. That's when you see 'as any' popup in TypeScript.


Sandro Maglione

Sandro Maglione

Software

Type safety can go a long way with TypeScript πŸš€

So far in fact that the actual internal implementation must fallback to as any, often.

That's what's behind a full type safe API πŸ‘‡


Type safe, but how far?

Type safety is an additional check that prevents a developer to misuse your API.

If you go the the extreme, you can prevent all (expected) runtime error with just types:

type ValidSlug = string & Brand.Brand<"ValidSlug">;
type ContentFolder = "articles" | "newsletter";

const getContent = ({ slug }: { readonly slug: ValidSlug }): `${ContentFolder}/${ValidSlug}` =>
  `articles/${slug}`;

The example above is extremely strict, which means capturing all the assumptions with types to prevent any wrong combination of parameters.

Here is an example from the IndexedDB effect module I am working on. Can you spot the issue? πŸ€”

yield* api.transaction(
  ["todo"],
  "readonly",
  (api) =>
    Effect.gen(function*() {
      return yield* api.from("todo").insert({ id: 2, title: "test2", completed: false })
    })
)

There is an hidden assumption when you specify "readonly": you can not insert πŸ™Œ

Turns how it's possible to prevent this issue on a type-level, making the API more type safe:

A readonly transaction does not contain methods to update the database, so no insert, upsert, delete, or clear.
A readonly transaction does not contain methods to update the database, so no insert, upsert, delete, or clear.

For the most courageous of you out there, here is the type to achieve this:

type Transaction = <
  Source extends IndexedDbVersion.IndexedDbVersion.AnyWithProps,
  Tables extends ReadonlyArray<
    IndexedDbTable.IndexedDbTable.TableName<
      IndexedDbVersion.IndexedDbVersion.Tables<Source>
    >
  >,
  Mode extends "readonly" | "readwrite" = "readonly"
>(
  tables: Tables & {
    0: IndexedDbTable.IndexedDbTable.TableName<
      IndexedDbVersion.IndexedDbVersion.Tables<Source>
    >
  },
  mode: Mode,
  callback: (api: {
    readonly from: <A extends Tables[number]>(
      table: A
    ) => Mode extends "readwrite" ? IndexedDbQueryBuilder.IndexedDbQueryBuilder.From<Source, A> :
      Omit<
        IndexedDbQueryBuilder.IndexedDbQueryBuilder.From<Source, A>,
        "insert" | "insertAll" | "upsert" | "upsertAll" | "clear" | "delete"
      >
  }) => Effect.Effect<void>,
  options?: globalThis.IDBTransactionOptions
) => Effect.Effect<void>

What if this goes too far?

From a user perspective, the more type safe the better πŸ’πŸΌβ€β™‚οΈ

But sometimes types become so strict that internally they don't match anymore.

That's why as has been created in TypeScript πŸ™Œ

As long as the runtime behavior is coherent with the type, then you can mess up as much as you want in the internal implementation πŸ˜…

Simple example:

const makeProto = <Id extends string>(options: {
  readonly identifier: Id
  readonly version: number
  readonly database: globalThis.IDBDatabase
  readonly IDBKeyRange: typeof globalThis.IDBKeyRange
}): IndexedDbDatabase.Service<Id> => {
  function IndexedDb() {}
  Object.setPrototypeOf(IndexedDb, Proto)
  IndexedDb.identifier = options.identifier
  IndexedDb.version = options.version
  IndexedDb.database = options.database
  IndexedDb.IDBKeyRange = options.IDBKeyRange
  return IndexedDb as any
}

This code returns an instance of IndexedDbDatabase by attaching properties to an initially empty function derived from a prototype, and then as any to satisfy TypeScript 🫑

Weird, but it works, and it's inline with the definition of IndexedDbDatabase πŸ’πŸΌβ€β™‚οΈ

Sometimes you as a developer know better than TypeScript πŸ‘€

"as any" is everywhere in library code

I first came around the merits of as and as any when contributing to xstate. Example:

const restoredSnapshot = createMachineSnapshot(
  {
    ...(snapshot as any),
    children,
    _nodes: Array.from(
      getAllStateNodes(getStateNodes(this.root, (snapshot as any).value))
    )
  },
  this
) as MachineSnapshot<
  TContext,
  TEvent,
  TChildren,
  TStateValue,
  TTag,
  TOutput,
  TMeta,
  TConfig
>

Types get so tangled, it's too much for TypeScript to keep prefect track. So you step in as developer and as any when necessary.

The key in library code is to then implement unit tests to verify the correct behavior πŸ‘ˆ

A beautiful type safe API, verified internally by strong and extensive tests

Since then, I became more lenient with the use of as (only in library code) πŸ™Œ

Another example from @typeonce/ecs:

export const Component = <Tag extends string>(
  tag: Tag
): {
  new <A extends Record<string, any> = {}>(
    args: Equals<A, {}> extends true
      ? void
      : {
          readonly [P in keyof A as P extends "_tag" ? never : P]: A[P];
        }
  ): { readonly _tag: Tag } & A;
  readonly _tag: Tag;
} => {
  class Base {
    readonly _tag = tag;
    constructor(args: any) {
      if (args) {
        Object.assign(this, args);
      }
    }
  }
  (Base.prototype as any).name = tag;
  (Base as any)._tag = tag;
  return Base as any;
};

The effect IndexedDB API is (nearly) done. It will follow a (somehow long) process of review and API adjustments before joining effect (hopefully) 🀞

After that I will become the first user of the module with a new app example, more news soon here of course πŸ”œ

See you next πŸ‘‹

Start here.

Every week I dive headfirst into a topic, uncovering every hidden nook and shadow, to deliver you the most interesting insights

Not convinced? Well, let me tell you more about it