DEV Community

Michael Sweeney
Michael Sweeney

Posted on

Pipetype: TypeScript Unions Bitwise Operators, and My Favorite Failed Experiment

Note: This is an actual library: https://github.com/overthemike/pipetype

1. A Tale of Two Pipes |

JavaScript is full of quirks, but few quirks are as weird and interesting as humble vertical bar: |.

  • In TypeScript, | is the union operator:
type ID = string | number
Enter fullscreen mode Exit fullscreen mode

→ “Either-or.” A value can be a string, or a number.

  • In JavaScript’s bitwise world, | flips switches:
1  |  2  //  3

0001 | 0010  ←  `|` tells you to look at each bit of both numbers and if either are present, mark it: 1, otherwise: 0
Let's put organize them vertically to make it easier to see.

0001  ←  1
0010  ←  2
  ↓↓
0011  ←  3
Enter fullscreen mode Exit fullscreen mode

→ “Both at once.” A new number with two switches turned on.

Two worlds. Same symbol. Different vibes.
And yet… they kind of rhyme.

  • TypeScript unions = a value can be validated by any of these types.
  • Bitwise OR = turn on any combination of these flags.

This has all of the markings of my newest rabbit hole experiment. How much and how close could we replicate TypeScript syntax at runtime?


2. BigInt Joins the Party

Bitwise tricks are fun, but JavaScript numbers only give you 53 safe bits.

That’s like having a switchboard with only 53 buttons:

  [ 1 ]  [ 2 ]  [ 3 ]  ...  [ 53 ]
Enter fullscreen mode Exit fullscreen mode

Great for a small project. Terrible when you want an arbitrary number of unions.

Enter BigInt. No limit on bits.

  [ 1 ]  [ 2 ]  [ 3 ]  ...  [ 1,000,000+ ]
Enter fullscreen mode Exit fullscreen mode

Infinite lockers. Infinite switches. No more ceiling.

BigInt became the foundation: every validator gets its own bit in an infinite skyline.


3. Bits as Pointers: The Validator Map

The whole trick works because each validator needs a unique bit — like giving every locker in a hallway its own light switch.


3.1 Bit Shifting 101

We start with the number 1n. In binary, that looks like:

1n = 0b0001
// '0b' is the prefix for binary
Enter fullscreen mode Exit fullscreen mode

Now apply a left shift (<<):

  • 1n << 0n → don’t move it → 0001 (still 1n)
  • 1n << 1n → move left once → 0010 (2n)
  • 1n << 2n → move left twice → 0100 (4n)
  • 1n << 3n → move left three times → 1000 (8n)

Each shift is multiplying by 2 — giving us the sequence of powers of 2:

1n, 2n, 4n, 8n, 16n, ...
Enter fullscreen mode Exit fullscreen mode

That’s why shifting is so useful here: every shift guarantees only one bit is on, so no two validators collide.

Start:  0001  (1n)

<< 1 →  0010  (2n)

<< 1 →  0100  (4n)

<< 1 →  1000  (8n)
Enter fullscreen mode Exit fullscreen mode

3.2 Using Shifts for Validators

This is how we assign unique “slots” for our validators:

const validators = new Map<bigint, (x: unknown) => boolean>()

validators.set(1n << 0n, (x) => typeof x === "string") // 0001
validators.set(1n << 1n, (x) => typeof x === "number") // 0010
validators.set(1n << 2n, (x) => Array.isArray(x))      // 0100
Enter fullscreen mode Exit fullscreen mode

Now, combining them is easy with |:

string | number → 0001 | 0010 = 0011 (3n)
Enter fullscreen mode Exit fullscreen mode

And testing is easy with &:

0011 & 0010 = 0010 → includes "number"
0011 & 0100 = 0000 → does NOT include "array"
Enter fullscreen mode Exit fullscreen mode

3.3 Feels Like Math Homework

This works, but writing 1n << 0n, 1n << 1n, 1n << 2n everywhere gets old fast.

So, we add a helper: getNextFlag() — so we can just ask for “the next available bit” instead of shifting by hand.

let lastFlag = 0n;

function getNextFlag(): bigint {
  lastFlag = lastFlag === 0n ? 1n : lastFlag << 1n;
  return lastFlag;
}
Enter fullscreen mode Exit fullscreen mode

3.4 Cleaner Validator Setup

Now assigning validators looks like this:

const validators = new Map<bigint, (x: unknown) => boolean>();

validators.set(getNextFlag(), (x) => typeof x === "string") // 0001
validators.set(getNextFlag(), (x) => typeof x === "number") // 0010
validators.set(getNextFlag(), (x) => Array.isArray(x))      // 0100
Enter fullscreen mode Exit fullscreen mode

Much easier to read, but you still know what’s happening under the hood: each new flag is just another power of 2.


4. Proxy: Turning Math into Sugar

All of this is neat, but nobody wants to write flags all day.

That’s where Proxy shines: we intercept property access and generate flags on the fly.

const Type = new Proxy({}, {
  get(_, key: string) {
    const bit = getNextFlag()
    validators.set(bit, (x) => typeof x === key)
    return bit
  }
})

const myType = Type.string | Type.number
Enter fullscreen mode Exit fullscreen mode

Now it looks like TypeScript.

Runtime JavaScript:

// defined elswhere
const string = Type.string((val) => typeof val === 'string')
const number = Type.number((val) => typeof val === 'number')

// your file
import { Type, string, number } from './types'

Type.StringOrNumber = string | number
Enter fullscreen mode Exit fullscreen mode

Underneath:

0001 | 0010 → 0011
Enter fullscreen mode Exit fullscreen mode

It’s really just syntactic sugar. JavaScript pretending to be TypeScript.

Well, it was. It's not really that useful.


5. The Dream vs. The Reality

At this point, the experiment felt magical:

  • Ergonomic unions at runtime.
  • BigInt masks for infinite validators.
  • Syntax that looked like TypeScript.

But then reality hit.

TypeScript’s compiler can’t see through BigInt.

From the type system’s perspective, these masks were just numbers. Opaque. Un-inferable.

The magic broke. Without inference, the ergonomics collapsed.
TypeScript couldn’t narrow. Couldn’t hint. Couldn’t help.

What started as sweet sugar ended as brittle runtime math.

There are ways of doing it, but they get kind of ugly. We already have fantastic and full-featured libraries like zod and valibot that bring inference for free for just a small syntax fee comparatively.


6. My Favorite Failed Experiment

That’s when I had to admit it: Pipetype failed.

It wasn’t the tool that would unify runtime and compile-time unions. It was a clever curiosity that buckled under TypeScript’s strict compiler gaze.

But I still love it.

  • It taught me how far | can stretch.
  • It showed me how BigInt changes the playground.
  • It reminded me how Proxies can cosplay as syntax.

Sometimes your best experiments are the ones that fail, because they teach you where the limits really are.


Appendix: Deep Cuts

  1. Bitwise as Lockers: OR (|) opens multiple lockers. AND (&) checks if a locker belongs.
0101 & 0001 → true  (locker 0 included)
0101 & 0010 → false (locker 1 not included)
Enter fullscreen mode Exit fullscreen mode
  1. BigInt for Infinite Validators: Beyond 53 bits, normal numbers wobble. BigInt? Infinite switches.

  2. Proxy for Sugar: Type.string wasn’t a string. It was a BigInt mask.

  3. Why It Failed: No TypeScript inference. Without narrowing, runtime unions lost their edge.


👉 That’s Pipetype: a mash-up of unions and bitmasks, Proxies and sugar, BigInts and dreams — and the lesson that sometimes failure is the most fun of all.

Top comments (2)

Collapse
 
prime_1 profile image
Roshan Sharma

Cool post! Loved your TypeScript union + bitwise experiment, clever idea, even if it hit some language limits.

Collapse
 
ahrjarrett profile image
andrew jarrett

This is so interesting. I don't quite understand it yet, but I always love seeing people pushing the boundaries of TypeScript!