TypeScript

What does it do?

TypeScript is a superset of JavaScript—a language that includes JavaScript's features and adds optional types. [1a][1b] It compiles to readable, standards-based JavaScript output. [2a][2b] It is intended for application-scale JavaScript and supports tools for large-scale JavaScript applications on any browser, host, or operating system. [3]

Activity, over time

Daily observations from the public event archive.

Attention

Daily stars & forks

Loading attention chart

Star accumulation

Cumulative stars

Loading star accumulation chart

Pull requests

Daily opens & merges

Loading pull requests chart

Conversations

Daily issues & comments

Loading conversations chart

Community

Daily accounts & reviews

Loading community chart

Releases

Daily releases

Loading releases chart

Mentions

32posts · 30d
Nov 30 posts
  1. Why Object.keys(user) Returns string[], Not (keyof User)[]: TypeScript Means It - Book: The TypeScript Type System - Also by me: The TypeScript Library — the 5-book collection - My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools - Me: xgabriel.com | GitHub --- Object.keys(user) as (keyof User)[] sits in a lot of TypeScript codebases, usually in a utils file next to a comment that blames the compiler. The TypeScript team has heard that complaint for about a decade. In May 2020, Ryan Cavanaugh of the TypeScript team wrote in a related issue that they "basically get a bug report or PR to change Object.keys to keyof T at least once a week" (microsoft/TypeScript#38520). The answer keeps being no, and it has been no since Anders Hejlsberg's objection on a 2016 pull request that tried to make the change (#12253). The no is deliberate. Once you see the reason, the cast is where the bug lives. The cast that type-checks and then throws The declaration in TypeScript's own lib file is one line: keys(o: object): string[]; (lib.es5.d.ts). Here is the program everyone writes to get around it: interface User { id: number; name: string; } const labels: Record<keyof User, string> = { id: "ID", name: "Full name", }; const row = { id: 1, name: "Ada", isAdmin: true }; const user: User = row; // compiles: row is not a fresh literal const keys = Object.keys(user) as (keyof User)[]; for (const k of keys) { console.log(labels[k].toUpperCase()); } This compiles under strict. Every line is legal. row has the type { id: number; name: string; isAdmin: boolean }, which is assignable to User because it has everything User asks for. The cast tells the compiler that keys holds only "id" and "name", so labels[k] is typed string and .toUpperCase() is allowed. At runtime, user and row are the same object. Object.keys returns the object's own enumerable string-keyed property names, all of them, so it returns ["id", "name", "isAdmin"]. The loop prints two labels, then reaches isAdmin, reads labels["isAdmin"], gets undefined, and calls .toUpperCase() on it. That is a TypeError, in code the compiler signed off on because the as told it to. !A cardboard box with a two-slot label on the front, while a third unlabeled item pokes out of the open lid A type lists what a value must have. The value can carry more. Structural typing: User is a floor, not a ceiling TypeScript checks shapes. A value is a User if it has an id: number and a name: string. Nothing in that rule says it can't have more. That is what makes structural typing pleasant day to day: you can pass a database row, a class instance or an API payload anywhere a narrower interface is expected, as long as the required fields are there. The same rule turns (keyof User)[] into a false promise. Hejlsberg put it this way on the 2016 pull request: "an object can (and often does) have more properties at run-time than are statically known at compile time" (#12253). His example in the same thread was a Base type: calling Object.keys on a Base and getting (keyof Base)[] back "is almost certainly wrong because an actual instance would be a derived object with more keys". You don't need inheritance to hit it. All of these hand a wider object to code that sees User: - a function render(u: User) called with a row variable that also carries role - JSON.parse(body) as User on a response that grew a field last sprint - an ORM row with createdAt and updatedAt that you typed as User - const withSession = { ...user, sessionId }, later passed to a User parameter In every case keyof User is a lower bound on the runtime keys. Object.keys is the runtime telling you the truth, and string[] is the type that matches it. What keyof actually computes keyof is a type-level operator with its own rules, and several of them diverge from what Object.keys can return: type K1 = keyof User; // "id" | "name" type K2 = keyof { [k: string]: boolean }; // string | number type K3 = keyof { [n: number]: unknown }; // number type K4 = keyof { 0: string }; // 0 type K5 = keyof {}; // never K2 surprises people. The TypeScript handbook explains that with a string index signature, keyof gives string | number "because JavaScript object keys are always coerced to a string, so obj[0] is always the same as obj["0"]". K4 goes the other way. Since TypeScript 2.9, keyof T is a subtype of string | number | symbol, and a number-like property name stays a number literal. Object.keys({ 0: "a" }) returns ["0"], a string. So even on an object with no extra properties, (keyof T)[] would put the number 0 in the type where the runtime gives you the string "0". Symbol keys show up in keyof and never in Object.keys, which returns only string-keyed properties (MDN). K5 is the case Hejlsberg called degenerate: {} accepts almost any value, yet keyof {} is never, so a keyof-typed Object.keys would claim every such object has no keys at all (#12253). Excess property checks only guard fresh literals "But TypeScript does reject extra properties," you might say. It does, in one place. The handbook says "object literals get special treatment and undergo excess property checking when assigning them to other variables, or passing them as arguments" (Object Types). The same page shows the escape hatch: assign the object to another variable first, and the check no longer runs. // Error: 'isAdmin' does not exist in type 'User' const a: User = { id: 1, name: "Ada", isAdmin: true }; // Fine: same value, routed through a variable const row = { id: 1, name: "Ada", isAdmin: true }; const b: User = row; The check is a lint for typos at the moment you write a literal, and it can't follow a value across a function boundary. A real guarantee would need exact object types, which have been an open proposal since December 15, 2016 (#12936), still labelled "Awaiting More Feedback". Until something like that ships, the language has no way to say "this User has exactly these keys", so Object.keys can't say it either. !Two containers side by side: a sealed glass case holding exactly two labeled objects, and an open crate with extra items spilling over the edge Typed keys are safe when you control what goes into the container. Four patterns that stay honest The common thread: the key type comes from something you built. 1. Own the key list. Write the keys once as a tuple and iterate that, never the object. const USER_FIELDS = ["id", "name"] as const satisfies readonly (keyof User)[]; type UserField = (typeof USER_FIELDS)[number]; function describe(u: User): string[] { return USER_FIELDS.map((k) => `${k}=${u[k]}`); } Extra properties on u are ignored because you never looked for them. The satisfies clause catches a typo in the list. It does not prove you listed every key; pattern 2 shows how to get that guarantee. 2. satisfies on a literal you wrote. The satisfies operator from TypeScript 4.9 validates "that the type of an expression matches some type, without changing the resulting type of that expression". On an object literal it runs the excess property check, and Record<keyof User, string> makes a missing key an error too. const userLabels = { id: "ID", name: "Full name", } satisfies Record<keyof User, string>; // Only for objects you created as literals. const literalKeys = <T extends object>(o: T) => Object.keys(o) as (keyof T & string)[]; for (const k of literalKeys(userLabels)) { console.log(k, userLabels[k].toUpperCase()); } This is still a cast. It is safe here only because the object is the literal you wrote three lines up and nothing adds keys to it. The & string drops the number and symbol members keyof can include. Pass a value typed User into literalKeys and you are back to the opening bug, which is why the helper's name says what it is for. 3. Use a Map and let it enforce the key type. MDN puts the difference plainly: "A Map does not contain any keys by default. It only contains what is explicitly put into it." const labelMap = new Map<keyof User, string>([ ["id", "ID"], ["name", "Full name"], ]); for (const [k, label] of labelMap) { console.log(k, label.toUpperCase()); } The constructor and set only accept keyof User keys, so the key type holds without a cast. labelMap.get(k) returns string | undefined, which is the correct type for a lookup. 4. Narrow strings from outside. For a parsed body or a query string, keep Object.keys as string[] and check each key against the list you own: function isUserField(k: string): k is UserField { return (USER_FIELDS as readonly string[]).includes(k); } function logUserFields(input: Record<string, unknown>) { for (const k of Object.keys(input)) { if (isUserField(k)) console.log(k, input[k]); } } Unknown keys fall through instead of getting a type they never had. Where to look first Object.keys answers "what keys does this value have right now", and no static type can answer that for a structurally typed value. Grep your codebase for as (keyof and check where each value came from. If your code wrote it as a literal and nothing widened it since, the cast is fine. If it arrived through a parameter or a parsed body, it is the opening bug waiting for a wider object. The next time someone files the weekly Object.keys issue, the answer is already in #12253. --- If this was useful The trick in this post, telling the compiler's key type apart from the runtime's keys, sits at the heart of The TypeScript Type System. It goes deep on generics, mapped and conditional types and branded types, so you can write helpers like literalKeys and know exactly where they stop being true. It is one of five books in The TypeScript Library. Books 1 and 2 are the core path; if you are coming from the JVM or PHP, book 3 or 4 covers that same ground from where you stand; book 5 is for anyone shipping TypeScript at work: - TypeScript Essentials — types, narrowing, modules, async, daily-driver tooling - The TypeScript Type System — generics, mapped/conditional types, infer, template literals, branded types - Kotlin and Java to TypeScript — JVM developers moving to TypeScript - PHP to TypeScript — PHP 8+ developers moving to TypeScript - TypeScript in Production — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR All five books ship in ebook, paperback, and hardcover. ![The TypeScript Library — the 5-book collection](xgabriel.com)

  2. u know i completely forgot i ever wrote this comment. what the hell TypeScript#33304

    6 likes
  3. Looks like this breaks Effect to some extent: TypeScript#64757 hopefully the @typescript team takes a look at it before 7.1

    75 likes · 2 reposts · 3,820 views
  4. X · · @uhyo_

    これがとても欲しい。TS 7.1に入ったりしないかな(?) TypeScript#64912

    3 likes · 1 repost · 714 views
  5. @itsjustcornbro @jarredsumner lots of potential here. much respect if Jarred gets this vision done TypeScript#47658

    2 likes · 34 views
  6. Tried tsrs, got a weird error, then tried the latest tsgo beta, same weird error. Bugs are ported cleanly 👌 TypeScript#64431

    35 likes
  7. What about Eli's question in 64040? @lishaduck.dev

    1 like
  8. TypeScript Is Unsound on Purpose: 7 Programs That Type-Check, Then Crash - Book: The TypeScript Type System - Also by me: The TypeScript Library — the 5-book collection - My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools - Me: xgabriel.com | GitHub --- Non-goal number three on the TypeScript Design Goals page reads: "Apply a sound or 'provably correct' type system. Instead, strike a balance between correctness and productivity." The team wrote that down on purpose. The handbook says the same thing in its own words. The type compatibility chapter has a section called "A Note on Soundness" that says TypeScript "allows certain operations that can't be known at compile-time to be safe," and that the places where it does so "were carefully considered." So a green tsc --strict run tells you your code stayed inside the trade-offs the team picked, and those trade-offs include programs that throw a TypeError. Below are seven programs that compile under --strict and fail at runtime, the rule that lets each one through, and what closes it, where something does. Every rule is linked to the doc that states it, so you can paste any snippet into the TypeScript playground and check it yourself. What --strict covers, and what it skips The strict flag turns on the strict family: strictNullChecks, strictFunctionTypes, noImplicitAny, useUnknownInCatchVariables and five more. Two checks that matter below are not in that family: noUncheckedIndexedAccess and exactOptionalPropertyTypes. The TypeScript 4.1 release notes say the index check "is not automatically enabled by the strict flag" because "it might be noisy for a lot of code." Every snippet here assumes --strict and nothing else. One of them dies the moment you add a single line to tsconfig.json. The rest need a pattern, a lint rule, or a runtime check, because no flag exists for them. Variance: the array and the method 1. A cat in the dog array class Animal { name = "animal"; } class Dog extends Animal { bark() { return "woof"; } } class Cat extends Animal { meow() { return "meow"; } } function adoptCat(shelter: Animal[]) { shelter.push(new Cat()); } const kennel: Dog[] = [new Dog()]; adoptCat(kennel); for (const dog of kennel) dog.bark(); // TypeError on the Cat Dog[] is assignable to Animal[] because arrays relate covariantly: if Dog fits Animal, then Dog[] fits Animal[]. That is safe for reading and wrong for writing. push takes an Animal, the Cat goes in, and the loop calls bark on a cat. The fix: when a function only reads, type the parameter as readonly Animal[]. push doesn't exist on that type, so the write becomes a compile error. Callers can still pass a Dog[], because the handbook notes that a mutable array assigns to a ReadonlyArray but not the other way around. 2. The method that only takes strings interface Handler { handle(input: string | number): void; } const h: Handler = { handle(input: string) { console.log(input.toUpperCase()); }, }; h.handle(42); // TypeError at runtime: no toUpperCase on 42 strictFunctionTypes arrived in TypeScript 2.6 and checks function parameters contravariantly. It skips methods on purpose. The release notes say the stricter check covers all function types "except those originating in method or constructor declarations," and that "methods are excluded specifically to ensure generic classes and interfaces (such as Array<T>) continue to mostly relate covariantly." The TSConfig reference adds that the team found "a large number of inherently unsafe class hierarchies, including some in the DOM" while building the flag. So a member written in method syntax stays bivariant, and an implementation that only accepts string gets through. That same carve-out is why program 1 compiles: push is a method on Array<T>. The fix: declare the member as a property, handle: (input: string | number) => void. Now the same object literal is a compile error. The typescript-eslint rule method-signature-style enforces property style by default, and its docs state that "methods are always bivariant in their arguments, while function properties are contravariant." Promises the checker doesn't track 3. A narrowed field that changes under you interface Profile { readonly nickname: string | null; } function greet(p: Profile, onGreet: () => void) { if (p.nickname !== null) { onGreet(); console.log(p.nickname.toUpperCase()); } } const live = { nickname: "kit" as string | null }; greet(live, () => { live.nickname = null; }); // TypeError at runtime: nickname is null The object types handbook page says TypeScript "doesn't factor in whether properties on two types are readonly when checking whether those types are compatible, so readonly properties can also change via aliasing." live is mutable, greet sees it through a readonly lens, and the callback writes through the original reference. Delete readonly from Profile and the program still compiles. The modifier was never the guard. What actually lets it through is narrowing. After p.nickname !== null, the compiler keeps that narrowing across the onGreet() call. Issue #9998, "Trade-offs in Control Flow Analysis", explains why: resetting every narrowing after every call is the pessimistic option, and Anders Hejlsberg called the optimistic assumption "the best compromise." No flag covers this one. Copy the value into a local before you call out, const nick = p.nickname;, then check and use nick. A callback can't reassign your const, so that narrowing holds. And keep the asymmetry in mind: readonly arrays are checked, readonly properties are not. 4. The key that isn't there const ports: Record<string, number> = { web: 8080 }; const db = ports["db"]; // typed as number console.log(db.toFixed(0)); // TypeError at runtime: db is undefined An index signature like Record<string, number> promises a number for any string key. ports["db"] is typed number and holds undefined. Arrays behave the same way: reading past the end gives you the element type, not undefined. The fix: noUncheckedIndexedAccess. It adds undefined to every read through an index signature and every array index, so db.toFixed stops compiling until you check it. The cost is noise in index-based for loops, and the 4.1 notes point you at for...of or forEach instead. Information lost on the way up 5. The optional property that was there all along interface Base { id: string; } interface Job { id: string; retries?: number; } const raw = { id: "job-1", retries: "three" }; const base: Base = raw; // extra property: fine const job: Job = base; // missing optional: fine console.log(job.retries?.toFixed(0)); // TypeError at runtime Two legal steps. raw to Base: excess property checks run only on a fresh object literal, and the handbook's excess property checks section says assigning the object to another variable gets around them. Base to Job: Base has no retries, and a missing property satisfies an optional one. In the middle, the compiler forgets retries existed. At the end it assumes number | undefined. The string "three" isn't nullish, so ?. doesn't save you. Nothing in tsconfig closes this. exactOptionalPropertyTypes only controls whether you may write undefined into an optional property. Exact object types, which would close it, have been an open proposal in issue #12936 since December 2016. When a reader raised this case on his Effective TypeScript repo, Dan Vanderkam called it "definitely another source of unsoundness" and pointed to Item 48 of his book, which covers it. The pattern that works: don't round-trip objects through a narrower interface, and validate data at the boundary where it enters your program. Where you told the compiler to trust you 6. The assertion that skipped the check interface Account { id: string; roles: string[]; } const body = '{"id":"u1"}'; const data: unknown = JSON.parse(body); const account = data as Account; console.log(account.roles.includes("admin")); // TypeError The everyday types handbook page says type assertions "are removed by the compiler," and that "there won't be an exception or null generated if the type assertion is wrong." The cast from unknown is allowed because assertions may move to a "more specific or less specific version of a type." account.roles is undefined at runtime. The fix: replace the cast with a runtime check, either a schema validator such as Zod or a hand-written guard (see program 7 before you trust one). To ban casts outright, typescript-eslint's consistent-type-assertions accepts assertionStyle: "never", which still allows as const. 7. The type guard that lies type Cat = { kind: "cat"; meow(): string }; type Dog = { kind: "dog"; bark(): string }; function isCat(pet: Cat | Dog): pet is Cat { return "bark" in pet; // inverted check, still compiles } function adopt(): Cat | Dog { return { kind: "dog", bark: () => "woof" }; } const pet = adopt(); if (isCat(pet)) { pet.meow(); // TypeError at runtime: a Dog has no meow } A type predicate is a promise you make. The compiler doesn't prove it. The narrowing handbook page says that whenever a guard is called, TypeScript "will narrow that variable to that specific type if the original type is compatible." It reads the signature. Nothing compares the body with the claim. Dan Vanderkam's post on unsoundness makes the same point: with a guard, "you're still asserting that your type guard really guards the type." The fix: since TypeScript 5.5, the compiler can infer a predicate from the body when four conditions hold: no explicit return type or predicate annotation, a single return, no mutation of the parameter, and a boolean expression tied to a refinement of the parameter. An inferred predicate comes from the check itself, so the two can't disagree. Keep explicit predicates for checks the compiler can't follow, and give each one a unit test with a matching and a non-matching value. Does TypeScript 7 change any of this? TypeScript 7.0 shipped on July 8, 2026, with the compiler ported to Go in the typescript-go repo. The announcement says the port kept "the structure and logic of the original codebase" to keep results consistent, and that 7.0 "is made to be compatible with TypeScript 6.0's type-checking and command-line behavior." The differences it lists are elsewhere: flags deprecated in 6.0 become hard errors, strict now defaults to true, template literal types preserve Unicode code points, JSDoc support was reworked, and changing the --checkers count can surface order-dependent results. None of those touch the seven rules above, so by the team's own compatibility statement all seven still compile on 7.0. The new strict default doesn't help either, since every program here already passes --strict. The seven, side by side: | # | Hole | What closes it | |---|---|---| | 1 | Array covariance | readonly T[] parameters | | 2 | Method bivariance | Property-style members, method-signature-style | | 3 | Narrowing kept across a callback | Copy to a const before callbacks | | 4 | Unchecked index reads | noUncheckedIndexedAccess | | 5 | Optional-property widening | Validate at the boundary (no flag) | | 6 | Assertions | Runtime validation, assertionStyle: "never" | | 7 | Lying type guards | Inferred predicates, tested guards | And the one config line that costs nothing to add: { "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true } } TypeScript gives you a checker that catches most mistakes and tells you in writing which ones it lets through. Add noUncheckedIndexedAccess today, and put the other six on your review checklist. --- If this was useful Every program in this post comes down to one question: what does assignability actually check? The TypeScript Type System is the book on that question. It covers variance and bivariance, including the function-versus-method split behind programs 1 and 2, plus the generic, conditional and branded types you reach for when the built-in rules are too loose. The TypeScript Library has five books. Start with the first two if TypeScript is your main language, swap in a bridge book if you are coming from the JVM or PHP, and add the fifth if you ship TypeScript at work: - TypeScript Essentials — types, narrowing, modules, async, daily-driver tooling - The TypeScript Type System — generics, mapped/conditional types, infer, template literals, branded types - Kotlin and Java to TypeScript — JVM developers moving to TypeScript - PHP to TypeScript — PHP 8+ developers moving to TypeScript - TypeScript in Production — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR All five books ship in ebook, paperback, and hardcover. ![The TypeScript Library — the 5-book collection](xgabriel.com)

    1 reaction · 1 comment
  9. If I recall correctly, a few of both - there were instances where some work could be done more lazily/minimally (TypeScript#64476), and there were instances where we changed our allocation patterns and played around with some struct layouts. We originally strived not to drastically reorganize our data structures to make things easier to port, and we have more flexibility now. We're continuing to experiment more here.

  10. Type a pipe() That Checks Every Step: Variadic Tuples, infer and One Recursive Type - Book: The TypeScript Type System - Also by me: The TypeScript Library — the 5-book collection - My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools - Me: xgabriel.com | GitHub --- Open Effect's Function.ts and count the signatures of pipe. There are 20: one for a bare value, then one more for every extra function, up to 19. RxJS 7's standalone pipe types nine functions and then falls back to a catch-all signature that returns UnaryFunction<T, unknown>. Hand it a tenth function and the output type is gone. Those libraries know exactly what they are doing. When TypeScript 4.0 shipped variadic tuple types, the release notes called this pattern "death by a thousand overloads", and overloads are still how the big libraries type pipe. The reason is that a single generic signature that checks every step is harder to write than it looks, and it gives some things up. You're going to write that single signature anyway: about 40 lines of types, checked by tsc --noEmit, and then the four places it breaks so you know when overloads win. Step 1: read the ends with infer Start with four small helpers. In and Out pull the parameter and return type out of a one-argument function. First and Last pull the first and last element out of a tuple. // pipe.ts export type Fn = (arg: any) => any; type In<F> = F extends (arg: infer A) => any ? A : never; type Out<F> = F extends (arg: any) => infer R ? R : never; type First<T extends unknown[]> = T extends [infer F, ...unknown[]] ? F : never; type Last<T extends unknown[]> = T extends [...unknown[], infer L] ? L : never; Last matches with a leading rest element, [...unknown[], infer L], which TypeScript has accepted since 4.2. With these you can write the obvious pipe type: the input of the first function, the output of the last. type NaivePipe<Fns extends Fn[]> = (input: In<First<Fns>>) => Out<Last<Fns>>; It reads the ends and never looks at the middle. Give it trim (string to string), len (string to number) and shout (string to string) and it reports (input: string) => string, even though shout would receive a number at runtime. A pipe type that only reads the ends is an as cast with better marketing. !A pipeline of joined segments where only the first and last joints are inspected and a mismatched joint in the middle goes unnoticed Reading only the first input and the last output leaves every joint in the middle unchecked. Step 2: walk the list with one recursive type The real check has to visit every adjacent pair. Recursive conditional types (TypeScript 4.1) let a type refer to itself in its own branches, so you peel one function off the front, compare its input with the value flowing in, and recurse on the rest. type PipeError<Step, Got, Want> = { readonly error: "pipe(): a step got the wrong input"; readonly step: Step; readonly got: Got; readonly want: Want; }; type Check< Fns extends Fn[], Input, Cur, Done extends unknown[] = [] > = Fns extends [infer F, ...infer Rest extends Fn[]] ? [Cur] extends [In<F>] ? Check<Rest, Input, Out<F>, [...Done, F]> : PipeError<[...Done, F]["length"], Cur, In<F>> : (input: Input) => Cur; export type Pipe<Fns extends [Fn, ...Fn[]]> = Check< Fns, In<First<Fns>>, In<First<Fns>> >; Four details carry the weight here. infer Rest extends Fn[] puts a constraint on the inferred rest, a feature from TypeScript 4.7. Without it, Rest can't be passed back into Check, whose first parameter demands Fn[]. [Cur] extends [In<F>] wraps both sides in brackets on purpose. A bare Cur extends ... on a type parameter is distributive: if a step returns string | number, the check would split into one branch per member and hand you a union of a function and an error. The brackets compare the whole union at once. Done collects the functions already visited. Its only job is to number the failing step: [...Done, F]["length"] is the 1-based position of F as a number literal type. The recursive call is the entire branch result. Nothing wraps it, and that makes Check tail-recursive, which matters when chains get long. The runtime part is short: export function pipe<Fns extends [Fn, ...Fn[]]>( ...fns: Fns ): Pipe<Fns> { const run = (input: unknown) => fns.reduce((acc: unknown, fn) => fn(acc), input); return run as unknown as Pipe<Fns>; } Fns extends [Fn, ...Fn[]] on a rest parameter makes TypeScript infer the arguments as a tuple, one element per function, which is what Check walks. The double cast is the honest part: the body can't prove a deferred conditional type, so you assert once at the boundary and callers get the checked type. The overloaded libraries pay the same toll in their own way; Effect's implementation signature is pipe(a: unknown, ...args: Array<any>): unknown. Step 3: prove it with @ts-expect-error A type you can't test is a type you will break by accident. // @ts-expect-error (TypeScript 3.9) suppresses the error on the next line, and if that line has no error, tsc reports Unused '@ts-expect-error' directive. That turns every expected failure into an assertion. // pipe.types.ts import { pipe } from "./pipe"; type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false; type Expect<T extends true> = T; const trim = (s: string) => s.trim(); const len = (s: string) => s.length; const isLong = (n: number) => n > 10; const shout = (s: string) => s.toUpperCase(); const ok = pipe(trim, len, isLong); export type OkIsTyped = Expect< Equal<typeof ok, (input: string) => boolean> >; // @ts-expect-error the input must be a string ok(42); const broken = pipe(trim, len, shout); // @ts-expect-error step 3 wants a string, gets a number broken(" hello "); // @ts-expect-error a broken pipe is not a function const f: (s: string) => string = broken; // @ts-expect-error pipe() needs at least one step pipe(); Equal compares two types strictly, so a result that drifts to any or to a wider type fails it; Expect fails to compile when handed false. A minimal tsconfig.json next to the two files: { "compilerOptions": { "strict": true, "noEmit": true, "target": "es2022", "module": "esnext", "moduleResolution": "bundler" }, "include": ["pipe.ts", "pipe.types.ts"] } Install the compiler with npm i -D typescript, then run npx tsc. The config needs TypeScript 5.0 or newer for "moduleResolution": "bundler"; the types themselves need 4.7. If any expectation stops holding, either an Expect line or an unused directive fails the build, locally or in CI. Where the recursive version breaks Look again at the broken test. The pipe(trim, len, shout) line compiles. The type of broken is PipeError<3, number, string>, and the error only appears where you call it or assign it to a function type. Hovering broken tells you the step, what it got and what it wanted, which beats a silent unknown, but the red squiggle sits on the wrong line. Overloads don't have this problem: each step is its own parameter, so a mismatch is an argument error on the exact function that's wrong. There is a variant that rewrites the rest parameter to the expected tuple so the error moves onto the bad argument; ecyrbe's write-up shows one. I haven't run it, and a commenter there reports a gap in its first-to-second step check, so test it the same way before you trust it. Generic steps lose their type parameter. Take const wrap = <T>(x: T): T[] => [x]. When In and Out match a generic signature against infer, the type parameter doesn't survive; TypeScript issue #38964 shows ReturnType of <T = string>() => T coming out as unknown. So pipe(trim, wrap, join), with join taking string[], fails with got: unknown[]. The fix is an instantiation expression (4.7): pass wrap<string> as the step and the chain checks. Overloads get this for free, because each step is matched against (a: A) => B with A already known, and since TypeScript 3.4 a composer can even propagate a generic step's type parameters into the result. Inline lambdas aren't typed from the previous step. In Effect, pipe(" hi ", (s) => s.trim(), (s) => s.length) types each s from the step before it, because every overload names the intermediate types as separate type parameters. In ...fns: Fns, nothing ties the second slot's parameter to the first slot's return, so an unannotated (s) => s.length has no way to learn that s is a string. The best it gets is the any from the Fn constraint, and an any step checks nothing. Annotate every inline step, or write the steps as named functions. Long chains hit the depth limit, depending on how you wrote the recursion. The TypeScript PR that added tail-recursion elimination lets tail-recursive conditional types loop 1,000 times before giving up, and lowered the general limit for the rest to 100. Check is tail-recursive, so a 30-step pipe is nowhere near the ceiling. Rewrite it so the recursive call sits inside something else, a tuple spread or a union, and you are back under the general limit, where the failure is the familiar Type instantiation is excessively deep and possibly infinite from the 4.5 release notes. Even well under the limit, the 4.1 notes warn that recursive types can increase type-checking time, so keep the step types themselves simple. When overloads are still the better choice !A tall stack of near-identical cards on one side and a single looping arrow passing through a row of function blocks on the other Overloads write one signature per chain length. The recursive type writes one signature that walks the list. The rule that falls out of the four breaks: choose by who writes the steps. If callers write steps inline, as lambdas, or pass generic helpers like map and filter, use overloads. That is the RxJS and Effect situation, where most steps are operators with type parameters and most callbacks are unannotated. Contextual typing per step and per-argument errors are worth more than an unlimited chain length, and very few real pipelines need more than the 19 functions Effect covers. If callers pass named, already-typed, non-generic functions, use the recursive type. Validation chains, middleware lists and data transforms assembled from a registry all fit: the steps already carry their own types, chains can be longer than any overload stack, and the PipeError type names the failing step. Ship it with the @ts-expect-error file so the next refactor can't quietly break the check. --- If this was useful The recursive Check type is a small version of a pattern that keeps coming back: walk a tuple with infer, carry state in an accumulator, keep the recursion in tail position. The TypeScript Type System goes deep on that toolkit: generics, mapped and conditional types, infer, template literals, branded types. If you already write TypeScript daily, that's the one to read next; if you're still getting comfortable with narrowing and modules, start with TypeScript Essentials. - TypeScript Essentials — types, narrowing, modules, async, daily-driver tooling - The TypeScript Type System — generics, mapped/conditional types, infer, template literals, branded types - Kotlin and Java to TypeScript — JVM developers moving to TypeScript - PHP to TypeScript — PHP 8+ developers moving to TypeScript - TypeScript in Production — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR Coming from Kotlin, Java or PHP? The two bridge books replace the first two for you. Shipping TypeScript at work, with build tooling and published packages? TypeScript in Production covers that layer. All five books ship in ebook, paperback, and hardcover. ![The TypeScript Library — the 5-book collection](xgabriel.com)

    5 reactions · 1 comment

Latest stargazers

The last 10 distinct accounts we observed this year.

Most active participants

PR, issue, comment & review events in the past year.

Packages

1
PackageVersionDownloads · 30dTrend · 30d
v9.0.3
≥31.1M

README badges

Embed a badge in your README or website.

  • microsoft/TypeScript reached #1 in daily stars on gitwal · Jul 22, 2014
  • microsoft/TypeScript reached #3 in weekly stars on gitwal · Week of Jul 21, 2014
Live mentions · last 30 days
microsoft/TypeScript mentions in the last 30 days