This content originally appeared on DEV Community and was authored by Michael Sweeney
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
→ “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
→ “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 ]
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+ ]
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
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, ...
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)
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
Now, combining them is easy with |
:
string | number → 0001 | 0010 = 0011 (3n)
And testing is easy with &
:
0011 & 0010 = 0010 → includes "number"
0011 & 0100 = 0000 → does NOT include "array"
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;
}
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
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
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
Underneath:
0001 | 0010 → 0011
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
-
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)
BigInt for Infinite Validators: Beyond 53 bits, normal numbers wobble. BigInt? Infinite switches.
Proxy for Sugar:
Type.string
wasn’t a string. It was a BigInt mask.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.
This content originally appeared on DEV Community and was authored by Michael Sweeney

Michael Sweeney | Sciencx (2025-09-26T02:27:46+00:00) Pipetype: TypeScript Unions Bitwise Operators, and My Favorite Failed Experiment. Retrieved from https://www.scien.cx/2025/09/26/pipetype-typescript-unions-bitwise-operators-and-my-favorite-failed-experiment/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.