The Functional Blueprint: Teaching TypeScript to Speak F#
TypeScript has become the lingua franca of LLMs: ask a modern AI assistant to write backend services, frontend components, or shared domain models and it will almost certainly default to TypeScript-like code. The style of TypeScript the model chooses, however, has a significant impact on safety and maintainability. If you come from an F# background, you already know a different way to write programs. F# emphasises immutable data, discriminated unions, exhaustive pattern matching, domain-driven modelling, and clear separation between pure logic and side effects, with the result that many classes of bugs are simply impossible to represent at the type level. This article is a blueprint for bending TypeScript in that direction, using concrete patterns that can be encoded into LLM prompts and into your codebase. Each section covers a core F# idea and shows:
- How to emulate them in TypeScript.
- Where parity is strong and where it breaks down.
- How to guide LLMs so they consistently use those patterns.
1. Discriminated Unions
In F#, discriminated unions (DUs) are the primary way to model domain states explicitly, rather than relying on booleans, flags, or “nullable” fields.
1.1 F# discriminated unions
type Shape =
| Circle of radius: float
| Rectangle of width: float * height: float
A Shape is either a Circle with a radius, or a Rectangle with width and height. Nothing else is allowed; the type is closed.
1.2 TypeScript discriminated unions
TypeScript can emulate this with discriminated unions.
type Shape =
| { kind: "circle", radius: number }
| { kind: "rectangle", width: number, height: number }
The kind field is the discriminant property. It lets the compiler narrow within a switch or if chain.
Boilerplate and gaps
Compared to F#, there is more boilerplate:
- You must choose and repeat a discriminant property (
kind,type, etc.). - Adding a new case means changing the union declaration plus all
switches that depend on it. For LLMs, you want to be explicit in your prompts:
“Model domain states as discriminated unions with a string
kinddiscriminant instead of using booleans, nullable properties, or loosely shaped objects.”
Even this one constraint significantly shapes the output style.
2. Exhaustive Pattern Matching (never + satisfies)
F#’s match is more than syntactic sugar: the compiler can tell you when you’ve forgotten to handle a case.
2.1 F# exhaustive pattern matching
let area shape =
match shape with
| Circle radius -> System.Math.PI * radius * radius
| Rectangle (w, h) -> w * h
If you later add | Triangle of base: float * height: float, the compiler warns unless you update area.
2.2 TypeScript exhaustive matching with satisfies never
TypeScript’s switch doesn’t enforce exhaustiveness by default, but you can force a compile-time check in the default branch using value satisfies never (introduced in TypeScript 4.9).
function getArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2
case "rectangle":
return shape.width * shape.height
default: {
throw new Error(`Unhandled shape: ${shape satisfies never}`)
}
}
}
If you add a new kind to Shape but forget to handle it here, the shape satisfies never line becomes a compile-time error.
This is the consumption-site exhaustiveness check.
2.3 Exhaustiveness at mapping sites
You can also enforce exhaustiveness where you define “total mappings”—for example, mapping each colour to a hex code.
type Color = "red" | "green" | "blue"
const ColorHex = {
red: "#f00",
green: "#0f0",
blue: "#00f",
} satisfies Record<Color, string> // Errors if any Color is missing
Here, satisfies Record<Color, string> ensures:
- Every
Colorkey is present. - The object’s literal types are preserved (you don’t widen to
Record<string, string>).
This is the definition-site check: the object must cover all keys in the union.
2.4 Best practices for LLMs
Encode this in your prompts:
“For every
switchon a discriminated union, add adefaultcase likedefault: { throw new Error(`Unhandled value: ${value satisfies never}`); }so that missing cases become compile-time errors.”
“For mappings over union keys, define objects that
satisfies Record<Union, T>to guarantee all keys are covered.”
Both together give TypeScript something close to F#’s match experience.
3. Immutability, Records, and Structural Equality
In F#, records are immutable by default and compared structurally.
3.1 F# records
type Person = { Name: string; Age: int }
let p1 = { Name = "Alice"; Age = 30 }
// p1.Age <- 31 // compile-time error: record fields are immutable
Two records with identical fields and values are equal.
3.2 TypeScript records with readonly
TypeScript emulates records with object types and the readonly modifier:
type Person = {
readonly name: string
readonly age: number
}
const p1: Person = { name: "Alice", age: 30 }
// p1.age = 31 // Error: Cannot assign to 'age' because it is a read-only property
For collections, use ReadonlyArray<T> or readonly T[]:
const people: ReadonlyArray<Person> = [
{ name: "Alice", age: 30 },
]
3.3 Structural equality gap
TypeScript uses reference equality for objects: { name: "Alice", age: 30 } === { name: "Alice", age: 30 } is false. You need helper functions or libraries (fast-deep-equal, etc.) for structural equality.
3.4 Non-destructive updates
F# has with for persistent updates:
let older = { p1 with Age = p1.Age + 1 }
In TypeScript, object spread is close:
const older: Person = { ...p1, age: p1.age + 1 }
This is more verbose but conceptually similar.
3.5 Prompting for immutable style
When steering LLMs:
- “Make all domain object properties
readonly.” - “Use
ReadonlyArrayfor lists and non-destructive updates via spread or pure functions.” - “Do not mutate function arguments or shared state.”
4. Option/Result vs null/undefined
F# avoids null by design, preferring Option<'a> and Result<'a, 'e>.
4.1 F# Option and Result
let divide x y =
if y = 0 then None
else Some (x / y)
let tryParseInt input =
match System.Int32.TryParse input with
| true, value -> Ok value
| false, _ -> Error "Not a number"
Returning Option or Result forces callers to handle missing data and errors explicitly.
4.2 TypeScript Option emulation
TypeScript relies on unions and strict null checking:
function divide(x: number, y: number): number | undefined {
return y === 0 ? undefined : x / y
}
You can then use optional chaining and nullish coalescing:
const result = divide(10, 2) ?? 0
For a more F#-like Option, you can define:
type Option<T> = { kind: "some", value: T } | { kind: "none" }
…but in practice, T | undefined often wins on ergonomics.
4.3 TypeScript Result emulation
A discriminated union is a natural fit:
type Result<T, E> =
| { ok: true, value: T }
| { ok: false, error: E }
function divideSafe(x: number, y: number): Result<number, "DivideByZero"> {
return y === 0
? { ok: false, error: "DivideByZero" }
: { ok: true, value: x / y }
}
Callers must inspect ok, mirroring F#’s Result. Libraries like neverthrow and Effect provide richer ecosystems around this idea.
4.4 LLM guidance
“Do not throw exceptions for domain-level failures. Instead, return a
Result<T, E>discriminated union.”
“Avoid
null; preferundefinedinT | undefinedunions or explicitOption/Resulttypes.”
Both patterns bring TS closer to F#’s explicit failure semantics.
5. Pipelines, Currying, and Composition
F#’s pipeline operator |> makes it easy to write left-to-right data flows.
5.1 F# pipelines
[1 .. 5]
|> List.map (fun x -> x * 2)
|> List.filter(fun x -> x > 5)
The data flows left to right, making the intent clear.
5.2 TypeScript pipelines today
For arrays, method chaining already reads left to right and there is no reason to reach for pipe.
Where pipe earns its keep is composing standalone domain functions that have no prototype methods to chain. Without it, multi-step transforms nest right to left — the opposite order to execution:
// Reads right to left — hard to follow
const label = formatCurrency(applyVat(applyDiscount(0.1)(basePrice)))
A pipe helper restores execution order:
type Unary<A, B> = (a: A) => B
function pipe<A, B>(a: A, ab: Unary<A, B>): B
function pipe<A, B, C>(a: A, ab: Unary<A, B>, bc: Unary<B, C>): C
function pipe<A, B, C, D>(a: A, ab: Unary<A, B>, bc: Unary<B, C>, cd: Unary<C, D>): D
// …more overloads…
function pipe(a: unknown, ...fns: Unary<any, any>[]): unknown {
return fns.reduce((v, f) => f(v), a)
}
const basePrice = 100
const applyDiscount = (pct: number) => (amount: number) => amount * (1 - pct)
const applyVat = (amount: number) => amount * 1.2
const formatCurrency = (amount: number) => `£${amount.toFixed(2)}`
// Reads left to right — steps in execution order
const label = pipe(basePrice, applyDiscount(0.1), applyVat, formatCurrency)
TC39’s pipeline proposal may eventually reduce the need for this, but for now, a standard pipe helper is a good pattern to encourage from LLMs.
5.3 Currying and partial application
In F#, all functions are curried automatically: a two-argument function is really a function that returns a function, so partial application requires no extra syntax:
let multiply x y = x * y
let double = multiply 2 // partially applied — double : int -> int
TypeScript has no automatic currying, but the same effect is straightforward using nested arrow functions:
// Two-argument form — cannot partially apply
const multiply = (x: number, y: number) => x * y
// Curried form — first call fixes x, second call provides y
const multiplyC = (x: number) => (y: number) => x * y
const double = multiplyC(2) // (y: number) => number
This matters for pipe, which expects unary functions at every step. The applyDiscount(0.1) call in §5.2 works precisely because applyDiscount is curried: calling it with one argument returns the unary function that pipe then applies to basePrice.
A non-curried helper would need wrapping:
const applyDiscountFlat = (pct: number, amount: number) => amount * (1 - pct)
// Must wrap to make it unary
const label = pipe(basePrice, x => applyDiscountFlat(0.1, x), applyVat, formatCurrency)
Preferring curried arrow functions eliminates those wrappers and keeps pipeline steps visually clean.
5.4 LLM guidance
“Prefer curried arrow functions for any function that will be partially applied or used as a pipeline step:
const f = (a: A) => (b: B) => ...rather than(a: A, b: B) => ....”
6. Units of Measure and Branded Types
Units of Measure are one of F#’s most distinctive features: you can’t accidentally add metres and feet.
6.1 F# Units of Measure
[<Measure>] type m
[<Measure>] type ft
let distanceInMeters : float<m> = 10.0<m>
let distanceInFeet : float<ft> = 32.8<ft>
// let total = distanceInMeters + distanceInFeet // compile-time error
6.2 TypeScript branded (nominal) types
In TypeScript’s structural system, number is number. To distinguish metres from feet, you “brand” them.
type Meters = number & { readonly __brand: "Meters" }
type Feet = number & { readonly __brand: "Feet" }
const Meters = (n: number): Meters => n as Meters
const Feet = (n: number): Feet => n as Feet
const dMeters = Meters(10)
const dFeet = Feet(32.8)
// let total: Meters = dMeters + dFeet // Type error: 'number' is not assignable to type 'Meters'
You can generalise this using unique symbol for extra safety:
declare const __brand: unique symbol
type Brand<T, TBrand> = T & { [__brand]: TBrand }
type UserId = Brand<string, "UserId">
type OrderId = Brand<string, "OrderId">
6.3 Why this matters (the “ID soup” problem)
LLMs naturally output things like:
function updateUser(id: string, orgId: string) { /* ... */ }
This is “ID soup”: any string can go anywhere. With branded types:
type UserId = Brand<string, "UserId">
type OrgId = Brand<string, "OrgId">
function updateUser(id: UserId, orgId: OrgId) { /* ... */ }
You cannot accidentally swap the arguments without a compile error.
6.4 Arithmetic and JSON gaps
- Arithmetic on brands tends to “forget” the brand (because the compiler sees
numberoperations), so you may need domain-specific helpers or re-branding. - When deserialising JSON, brands don’t exist at runtime; you must re-validate and brand at the boundary (e.g. using Zod).
6.5 Prompting for branded types
“Define branded (nominal) types for all domain identifiers (e.g.
UserId,OrderId) and measurements. Functions that operate on those values must use the branded types so they cannot be confused.”
The result is F#-like domain safety at the type level.
7. Active Patterns, Matchers, and Prisms
Active Patterns in F# are an advanced feature that let you define views over data. They encode classification logic directly into pattern matching.
7.1 Multi-case active patterns in F#
let (|Email|Phone|Unknown|) (input: string) =
if input.Contains("@") then Email input
elif input |> Seq.forall System.Char.IsDigit
then Phone input
else Unknown
let describe input =
match input with
| Email addr -> $"Email: {addr}"
| Phone num -> $"Phone: {num}"
| Unknown -> "Unknown contact method"
The pattern both classifies and transforms the data.
7.2 TypeScript “matcher function” pattern
TypeScript doesn’t have syntax to transform during the switch head, so you do it just before, via a matcher function that returns a discriminated union.
type ContactMethod =
| { type: "Email", address: string }
| { type: "Phone", number: string }
| { type: "Unknown" }
const asContactMethod = (input: string): ContactMethod => {
if (input.includes("@")) return { type: "Email", address: input }
if (/^\d+$/.test(input)) return { type: "Phone", number: input }
return { type: "Unknown" }
}
const method = asContactMethod("test@example.com")
switch (method.type) {
case "Email":
console.log(method.address)
break
case "Phone":
console.log(method.number)
break
case "Unknown":
break
default: {
throw new Error(`Unhandled ContactMethod: ${method satisfies never}`)
}
}
The result is effectively a hand-built active pattern.
7.3 Partial active patterns and T | undefined
Partial active patterns—(|Integer|_|)—either match or they don’t. In TS, you can represent this via T | undefined:
const asInteger = (value: string): number | undefined => {
const parsed = parseInt(value, 10)
return Number.isNaN(parsed) ? undefined : parsed
}
const n = asInteger("42") ?? 0
This pairs nicely with nullish coalescing and avoids boolean blindness.
7.4 Prisms and lenses
In functional optics:
- A Lens focuses on a part that always exists (e.g.
User.name). - A Prism focuses on a part that might exist, often inside a union (e.g. the
CircleinsideShape).
Matcher functions are hand-built prisms: given arbitrary input, return either { type: "Email"; ... } or { type: "Unknown" }.
Asking LLMs to produce matchers rather than deeply nested if chains produces more F#-like structure.
7.5 Prompting for classification-first design
“Don’t embed complex business logic directly in UI handlers or controllers. Instead, create ‘matcher’ functions that classify raw inputs into discriminated unions, then handle those via exhaustive
switchwith asatisfies neverexhaustiveness check.”
This separates classification from handling, much like active patterns.
8. Boolean Blindness and Type-Level Literals
Boolean blindness is what happens when a boolean returns from a function but the reason behind true or false is not encoded anywhere.
8.1 The blind approach
function canAccess(user: User): boolean {
// complex rules...
return user.role === "admin" || user.isSubscriber
}
if (canAccess(user)) {
// But *why* was access granted? The type system has no idea.
}
You lose all nuance: “admin”, “subscriber”, “grace period”, etc.
8.2 Union-of-reasons result
Instead, return a discriminated union:
type Access =
| { kind: "Allowed", reason: "Admin" | "Subscriber" }
| { kind: "Denied", reason: "Unauthorized" | "Expired" }
function canAccess(user: User): Access {
if (user.role === "admin")
return { kind: "Allowed", reason: "Admin" }
if (user.isSubscriber)
return { kind: "Allowed", reason: "Subscriber" }
return { kind: "Denied", reason: "Unauthorized" }
}
The caller can then distinguish why access was allowed or denied.
8.3 Template literal types as parameterised patterns
Template literal types let you encode information into string shapes.
type Px = `${number}px`
type Percent = `${number}%`
type Length = Px | Percent
function setWidth(value: Length) {
// `value` is guaranteed to be "Npx" or "N%"
}
// setWidth("100") // Error
setWidth("100px") // OK
setWidth("50%") // OK
This is similar in spirit to parameterised active patterns like Range(1, 10)—you constrain shape at the type level before runtime.
You can also encode parameterised checks:
type MultipleOf<N extends number> = `MultipleOf_${N}`
function checkFactor<N extends number>(
n: number,
factor: N
): MultipleOf<N> | "NoMatch" {
return n % factor === 0
? (`MultipleOf_${factor}` as MultipleOf<N>)
: "NoMatch"
}
The result type carries the factor it matched against.
8.4 Assertion functions
Where type-level encodings become unwieldy, assertion functions are the TS analogue of pattern-based narrowing:
type PositiveNumber = Brand<number, "PositiveNumber">
function assertIsPositive(n: number): asserts n is PositiveNumber {
if (n <= 0) {
throw new Error("Not positive")
}
}
After calling assertIsPositive(value), the compiler treats value as PositiveNumber inside that scope.
8.5 Prompting away from booleans
“Avoid returning
booleanfor domain decisions. Return a union of literal types or a discriminated union that encodes the reason for the outcome.”
“Use template literal types for domain-specific string formats: e.g.
ID_${string},${number}px, etc.”
Both rules reduce boolean blindness and push toward F#’s DU-heavy style.
9. Lists, Arrays, and Persistent Data Structures
F# lists are immutable linked lists; JS/TS arrays are mutable, indexed sequences.
9.1 F# lists
let numbers = [1; 2; 3; 4; 5] // 'a list
let more = 0 :: numbers // prepend is O(1)
F# also has arrays and other structures, but lists are idiomatic for many recursive and functional patterns.
9.2 TypeScript arrays and ReadonlyArray
TypeScript’s default T[] is mutable. To approximate F# lists, use ReadonlyArray<T>:
const xs: ReadonlyArray<number> = [1, 2, 3]
// xs.push(4) // Error: Property 'push' does not exist on type 'readonly number[]'
const ys = [0, ...xs] // creates a new array
9.3 Performance implications
The illusion of immutability through spreads has a cost: [newItem, ...oldArray] copies the entire array. For small lists (UI-level code) this is usually fine; for high-throughput data processing, consider:
- Structuring transforms as pipelines without excessive copying.
- Using libraries providing persistent data structures (e.g. Immutable.js, Immer in “copy-on-write” style).
9.4 Prompting for immutable collections
“Use
ReadonlyArray<T>and avoid mutating arrays in place (push,splice,sortin place). Use non-mutating methods (e.g.map,filter) or return new arrays.”
This preserves the functional, data-in/data-out character of F#’s list processing.
10. Modules vs Companion Objects and File Modules
In F#, you group related types and functions into modules, giving you names like List.map or User.create.
10.1 F# modules
module User =
type T = { Id: int; Name: string }
let create id name = { Id = id; Name = name }
let validate (user: T) =
user.Name.Length > 0
10.2 TypeScript’s “companion object” pattern
In modern TypeScript, the primary unit of modularity is the ES module (file). To mimic F# modules, define:
- A
typefor the data. - A
const(companion object) with the same name containing pure functions.
// User.ts
export type User = {
readonly id: UserId
readonly name: string
}
export const User = {
create(id: UserId, name: string): User {
return { id, name }
},
validate(user: User): boolean {
return user.name.length > 0
},
}
Note on imports: exporting a type User and a const User from the same module is legal (types and values live in different namespaces), but when importing from another file you may want import type { User } from "./User"; alongside import { User } from "./User"; depending on whether you need the type, the value, or both.
Usage:
const user = User.create(userId, "alice")
if (User.validate(user)) { /* ... */ }
This gives the Module.function style familiar from F#.
10.3 Namespaces (and why to avoid them)
TypeScript also has namespace:
namespace User {
export type T = { /* ... */ }
export const create = /* ... */
}
This looks very F#-like, but is discouraged in modern code because it doesn’t play nicely with tree-shaking and bundlers compared to ES modules.
10.4 Public API control
F# supports .fsi signature files that explicitly declare which types and functions are visible outside a module — though in practice many projects skip them. .fsi files are entirely optional: without one, all top-level bindings in the .fs file are implicitly public. The main cost is the maintenance overhead of keeping .fsi and .fs in sync.
TypeScript achieves the same goal through export and “barrel” files:
- Anything not marked
exportis invisible to importers, making unexported helpers effectively module-private. - A top-level
index.tscan re-export only the symbols external consumers should see, hiding implementation details that live in other files in the same folder.
10.5 Prompting for module structure
“Organise the code using a module-per-domain-type approach. For each domain entity, create a file defining the
typeand aconstwith the same name that contains pure functions. Do not use classes.”
This discourages anemic class patterns in favour of F#-like module design.
11. Consolidated Mapping: F# Features to TypeScript Patterns
| F# feature | TypeScript “functional” equivalent | Parity level / notes |
|---|---|---|
| Discriminated Union | Discriminated union with a kind discriminant |
High; more boilerplate |
| Exhaustive pattern match | switch + value satisfies never (and satisfies Record<...> for total mappings) |
High at usage and mapping sites |
| Active Patterns | Matcher functions, prisms returning discriminated unions or T | undefined |
Medium; manual ceremony |
| Option | T | undefined or Option<T> DU |
High in practice |
| Result | Result<T, E> discriminated union or neverthrow/Effect |
High with discipline |
| Records | type/interface + readonly |
High; no built-in structural equality |
| Units of Measure | Branded types (Brand<T, Tag>) |
Medium; arithmetic & JSON require helpers |
| Lists (linked) | ReadonlyArray<T> or persistent data structures library |
Low–medium; semantics differ |
| Pipelines | Chaining or pipe(...) helper |
Medium; pending pipeline operator |
| Automatic currying | Curried arrow functions (a: A) => (b: B) => ... |
Medium; explicit, no language support |
| Modules | File modules + companion objects | High in practice |
| Null avoidance | strictNullChecks, undefined, explicit unions |
High with discipline |
| Boolean-free domain logic | Unions of literal reasons, template literal types | High; requires design and conventions |
12. A “Golden Prompt Library” for F#-Style TypeScript
These prompts can be combined into a reusable header that you paste into LLM sessions when generating code.
12.1 Structural modelling
“Model domain data with discriminated unions and immutable records: define
typealiases with a stringkinddiscriminant andreadonlyfields. UseReadonlyArray<T>for collections.”
12.2 Exhaustiveness
“All
switchstatements over discriminated unions must be exhaustive. Add adefaultbranch likedefault: throw new Error(`Unhandled value: ${value satisfies never}`);so that missing cases cause compile-time errors.”
12.3 Domain safety and branded types
“Define branded (nominal) types for all domain identifiers (e.g.
UserId,OrgId,OrderId) and critical measurements usingBrand<T, Tag>ornumber & { readonly __brand: ... }. Functions must accept these branded types, not raw primitives.”
12.4 Error handling and options
“Avoid
nulland throwing for normal domain errors. Use union types withundefined(T | undefined) for optional values and aResult<T, E>discriminated union for operations that can fail.”
12.5 Logic structure and active patterns
“Separate classification from handling: create matcher functions that transform raw inputs into discriminated unions (like F# active patterns), and then handle them via exhaustive
switchstatements.”
12.6 Organisation and style
“Organise code by domain module: one file per entity with a
typefor data and aconstwith the same name providing pure functions. Do not use classes; prefer pure functions, immutable data, and pipelines over mutation.”
12.7 Pipelines and currying
“Prefer curried arrow functions for any function that will be partially applied or used as a pipeline step:
const f = (a: A) => (b: B) => ...rather than(a: A, b: B) => .... Use apipehelper to compose sequences of such functions left to right.” Wording can be adjusted to suit the model, but together these constraints steer generated TypeScript toward a more functional, F#-inspired style.
TypeScript is flexible enough to host most of F#’s core ideas. Encoding these patterns into LLM prompts means that flexibility works in your favour rather than against it.
Appendix A: External Libraries
The libraries below are referenced in the main text. All are in common use as of early 2026; maintenance cadence varies by project, so check “Last publish” and repository activity before standardising on any dependency.
Structural equality
fast-deep-equal — npmjs.com/package/fast-deep-equal
Tiny, fast deep-equality function. Useful wherever TypeScript’s reference equality (===) falls short for value comparisons on plain objects and arrays. No dependencies; ~111 million weekly downloads.
Result and error-handling
neverthrow — npmjs.com/package/neverthrow
Provides Result<T, E> and ResultAsync<T, E> types with a fluent API for chaining operations without throwing. Lightweight and focused; ~1.3 million weekly downloads.
Effect — effect.website · Effect vs fp-ts · npmjs.com/package/effect
A comprehensive functional programming library for TypeScript, covering effects, concurrency, streaming, dependency injection, and more. The Effect docs describe it as the successor to fp-ts v2 (“fp-ts v3”) and note that fp-ts is officially merging into the Effect ecosystem. Suitable for larger codebases that want a complete functional ecosystem.
Runtime validation and branded types
Zod — zod.dev · npmjs.com/package/zod
Schema declaration and runtime validation library. The standard choice for validating external data (API responses, form inputs, JSON) and branding the parsed output with a precise type. ~100 million weekly downloads.
Immutable data structures
Immutable.js — immutable-js.com · npmjs.com/package/immutable
Persistent data structures (List, Map, Set, Record, etc.) with structural sharing. Values are immutable by construction, not just by TypeScript annotation, making accidental mutation impossible at runtime.
Immer — immerjs.github.io/immer · npmjs.com/package/immer
Copy-on-write updates via a produce function that accepts a mutable draft. The mutation is structural-shared under the hood, so callers get an immutable result without writing spread-heavy update code. ~30 million weekly downloads.
Appendix B: The TC39 Pipeline Operator
Section 5 mentions TC39’s pipeline operator proposal as a potential future improvement to pipe helper ergonomics. Its progress is worth understanding before relying on it.
(If you want to track the proposal directly, the repository is: https://github.com/tc39/proposal-pipeline-operator)
Current status
The proposal is at Stage 2 (of 4) and has been there for several years. The most recent substantive activity on the repository is from 2022–2023. Stage 2 means the committee considers the problem worth solving and the general design plausible, but it does not indicate imminent standardisation.
The Hack vs F# dispute
Two flavours of the operator were proposed:
- F# pipes (
value |> f |> g) — the righthand side must be a unary function, making point-free style concise. TC39 rejected this design twice, citing memory-performance concerns from browser-engine implementors,awaitintegration difficulties, and worries about encouraging a style that requires currying libraries to be practical. - Hack pipes (
value |> f(%) |> g(%)) — the righthand side is any expression with a%placeholder. This is the surviving proposal. It is more verbose for unary function calls but works naturally with methods, arithmetic,await, and other expressions.
Practical implications
Because F# pipes were rejected and Hack pipes are stalled at Stage 2, a hand-written pipe helper remains the practical approach for linearising function composition in TypeScript today. The helper pattern in §5.2 is fully type-safe, requires no build tooling beyond standard TypeScript, and will not change behaviour if or when a native operator eventually ships.
If the proposal does advance, TypeScript would need to add support independently, and there would likely be a transitional period where both the helper and the operator coexist.