npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

ts-brand

v0.2.0

Published

Reusable type branding in TypeScript

Downloads

44,797

Readme

ts-brand

With ts-brand, you can achieve nominal typing by leveraging a technique that is called "type branding" in the TypeScript community. Type branding works by intersecting a base type with a object type with a non-existent property. It is closely related in principal and usage to Flow's opaque type aliases.

Installation

npm install --save ts-brand

Motivation and Example

Let's say we have the following API:

declare function getPost(postId: number): Promise<Post>;
declare function getUser(userId: number): Promise<User>;

interface User {
  id: number;
  name: string;
}

interface Post {
  id: number;
  authorId: number;
  title: string;
  body: string;
}

We want to leverage this API to write a function that, given a post's ID, can retrieve the user who wrote said post:

function authorOfPost(postId: number): Promise<User> {
  return getPost(postId).then(post => getUser(post.id));
}

Do you spot the bug? We're passing post.id to getUser, but we should have passed post.authorId:

function authorOfPost(postId: number): Promise<User> {
  return getPost(postId).then(post => getUser(post.authorId));
}

Nominal typing gives us a way to avoid conflating a user ID with a post ID, even though they are both numbers:

import {Brand} from 'ts-brand';

declare function getPost(postId: Post['id']): Promise<Post>;
declare function getUser(userId: User['id']): Promise<User>;

interface User {
  id: Brand<number, 'user'>;
  name: string;
}

interface Post {
  id: Brand<number, 'post'>;
  authorId: User['id'];
  title: string;
  body: string;
}

We have:

  • Defined the ID types in terms of branded types with different branding types
  • Substituted ad-hoc number types with a lookup type, thus designating the interface as the centerpiece
  • Retained the same runtime semantics as the original code
  • Made our original buggy example fail to compile

There is one more risk left. If someone else were to define a different kind of Post, and also wrote Brand<number, 'post'>, it would still be possible to conflate the two accidentally. To solve this, we can take advantage of the fact that TypeScript interfaces can be recursive:

interface User {
  id: Brand<number, User>;
  name: string;
}

interface Post {
  id: Brand<number, Post>;
  authorId: User['id'];
  title: string;
  body: string;
}

By defining the ID type in terms of the interface surrounding it, we have made it such that the only way an ID can be treated like a Post['id'] is if its branding type matches the structure of Post exactly.

API

This module exports four type members and two function members.

type Brand<Base, Branding, [ReservedName]>

A Brand is a type that takes at minimum two type parameters. Given a base type Base and some unique and arbitrary branding type Branding, it produces a type based on but distinct from Base. The resulting branded type is not directly assignable from the base type, and not mutually assignable with another branded type derived from the same base type.

Take care that the branding type is unique. Two branded types that share the same base type and branding type are considered the same type! There are two ways to avoid this.

The first way is to supply a third type parameter, ReservedName, with a string literal type that is not __type__, which is the default.

The second way is to define a branded type in terms of its surrounding interface, thereby forming a recursive type. This is possible because there are no constraints on what the branding type must be. It does not have to be a string literal type, even though it often is.

The third way is to define a branded type using an empty enum. In TypeScript, enums are nominally typed: two structurally identical enums are not considered the same type. This prevents two enum-branded types from ever being conflated for one another.

Examples:

type Path = Brand<string, 'path'>;
type UserId = Brand<number, 'user'>;
type DifferentUserId = Brand<number, 'user', '__kind__'>;
interface Post {
  id: Brand<number, Post>;
}
enum TimeTag {}
type Time = Brand<number, TimeTag>;

type AnyBrand

An AnyBrand is a branded type based on any base type branded with any branding type. By itself it is not useful, but it can act as type constraint when manipulating branded types in general.

type BaseOf<B extends AnyBrand>

BaseOf is a type that takes any branded type B and yields its base type.

type Brander<B extends AnyBrand>

A Brander is a function that takes a value of some base type and casts that value to a branded type derived from said base type. It can be thought of as the type of a "constructor", in the functional programming sense of the word.

Example:

type UserId = Brand<number, 'user'>;
// A Brander<UserId> would take a number and return a UserId

function identity<B extends AnyBrand>(underlying: BaseOf<B>): B

A generic function that, when given some branded type, can take a value with the base type of the branded type, and cast that value to the branded type. It fulfills the contract of a Brander.

At runtime, this function simply returns the value as-is.

Example:

type UserId = Brand<number, 'user'>;
const UserId: Brander<UserId> = identity;

function make<B extends AnyBrand>(): Brander<B>

Produces a Brander<B>, given a brand type B. By default this returns identity but relies on type inference to give the return type the correct type.

Example:

type UserId = Brand<number, 'user'>;
const UserId = make<UserId>();
const myUserId = UserId(42);

Optionally, you may provide a validation function to assert that the value is the expected data shape. This may be done by passing a function to make:

type UserId = Brand<number, 'user'>;
const UserId = make<UserId>((value) => {
  if (value <= 0) {
    throw new Error(`Non-positive value: ${value}`);
  }
});
UserId(42); // Ok
UserId(-1); // Error: Non-positive value: -1

Complete Example

We can form a cohesive, intuitive API definition by leveraging several features at once, such as namespace merging, type inference, and lookup types:

import {Brand, make} from 'ts-brand';

export interface User {
  id: Brand<number, User>;
  name: string;
}

export namespace User {
  export type Id = User['id'];
  export const Id = make<Id>();
}

export interface Post {
  id: Brand<number, Post>;
  authorId: User.Id;
  title: string;
  body: string;
}

export namespace Post {
  export type Id = Post['id'];
  export const Id = make<Id>();
}

declare function getPost(id: Post.Id): Promise<Post>;
declare function getUser(id: User.Id): Promise<User>;