Skip to content

Commit d088fef

Browse files
authored
Merge pull request #202 from ping-maxwell/experimental
fix(experimental): export FnErrorsOf for portable declaration emit
2 parents bb2368b + f029f86 commit d088fef

5 files changed

Lines changed: 137 additions & 10 deletions

File tree

packages/experimental/src/declaration-emit.test.ts

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,22 @@
11
import { execFileSync } from "node:child_process";
2-
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
2+
import {
3+
existsSync,
4+
mkdirSync,
5+
mkdtempSync,
6+
readFileSync,
7+
rmSync,
8+
symlinkSync,
9+
writeFileSync,
10+
} from "node:fs";
311
import { tmpdir } from "node:os";
412
import { dirname, join } from "node:path";
513
import { fileURLToPath } from "node:url";
614
import { describe, expect, it } from "vitest";
715

816
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
917
const fixtureDir = join(root, "test/declaration-emit");
18+
const consumerFixtureDir = join(root, "test/declaration-emit-consumer");
19+
const tsc = join(root, "../../node_modules/typescript/bin/tsc");
1020

1121
/** Soft ceiling well under TS7056 (~1e6 chars). Pre-fix auth emits were
1222
* ~750KB for two fns because ScopeOf/ResolvedVars inlined the module graph. */
@@ -18,13 +28,7 @@ describe("declaration emit (TS7056)", () => {
1828
try {
1929
execFileSync(
2030
process.execPath,
21-
[
22-
join(root, "../../node_modules/typescript/bin/tsc"),
23-
"-p",
24-
join(fixtureDir, "tsconfig.json"),
25-
"--outDir",
26-
outDir,
27-
],
31+
[tsc, "-p", join(fixtureDir, "tsconfig.json"), "--outDir", outDir],
2832
{ cwd: root, stdio: "pipe" },
2933
);
3034

@@ -45,3 +49,64 @@ describe("declaration emit (TS7056)", () => {
4549
}
4650
});
4751
});
52+
53+
describe("declaration emit (TS2883 / package entry)", () => {
54+
it("exports e.fn with errors through better-call package entry under node16", () => {
55+
expect(
56+
existsSync(join(root, "dist/index.d.mts")),
57+
"dist/index.d.mts missing - run pnpm build in packages/experimental",
58+
).toBe(true);
59+
60+
const consumerDir = mkdtempSync(join(tmpdir(), "bc-decl-consumer-"));
61+
try {
62+
mkdirSync(join(consumerDir, "node_modules"));
63+
symlinkSync(root, join(consumerDir, "node_modules/better-call"));
64+
symlinkSync(
65+
join(consumerFixtureDir, "index.ts"),
66+
join(consumerDir, "index.ts"),
67+
);
68+
symlinkSync(
69+
join(consumerFixtureDir, "tsconfig.json"),
70+
join(consumerDir, "tsconfig.json"),
71+
);
72+
// Written here (not checked in) so this fixture is not a pnpm
73+
// workspace package that knip would police for unlisted deps.
74+
writeFileSync(
75+
join(consumerDir, "package.json"),
76+
JSON.stringify({
77+
name: "better-call-declaration-emit-consumer",
78+
private: true,
79+
type: "module",
80+
}),
81+
);
82+
83+
try {
84+
execFileSync(process.execPath, [tsc, "-p", "tsconfig.json"], {
85+
cwd: consumerDir,
86+
stdio: "pipe",
87+
});
88+
} catch (err) {
89+
const e = err as { stderr?: Buffer; stdout?: Buffer };
90+
throw new Error(
91+
[
92+
"consumer declaration emit failed:",
93+
e.stderr?.toString("utf8"),
94+
e.stdout?.toString("utf8"),
95+
]
96+
.filter(Boolean)
97+
.join("\n"),
98+
);
99+
}
100+
101+
const dts = readFileSync(join(consumerDir, "out/index.d.ts"), "utf8");
102+
expect(dts).toMatch(/export declare const signInEmail:/);
103+
expect(dts).toMatch(/export declare const bound:/);
104+
// Portable via package entry - not better-call/dist/fn.mjs (TS2883).
105+
expect(dts).toMatch(/import\("better-call"\)\.FnErrorsOf/);
106+
expect(dts).toMatch(/import\("better-call"\)\.BoundCall/);
107+
expect(dts).not.toMatch(/dist\/fn\.mjs/);
108+
} finally {
109+
rmSync(consumerDir, { recursive: true, force: true });
110+
}
111+
});
112+
});

packages/experimental/src/fn.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,9 @@ type CallArgs<A, I> = I extends readonly unknown[]
9191
? [input?: A, parent?: ParentContext]
9292
: [input: A, parent?: ParentContext];
9393

94-
/** The union of a fn's DECLARED errors, as thrown values. */
94+
/** The union of a fn's DECLARED errors, as thrown values.
95+
* Re-exported from the package entry - `.try` / used-fn results surface
96+
* this in inferred types, and declaration emit needs a portable name. */
9597
export type FnErrorsOf<Er> = {
9698
[T in keyof Er & string]: FnError<T, InferInput<Er[T]>>;
9799
}[keyof Er & string];
@@ -191,7 +193,9 @@ type PublicFn<
191193
O = unknown,
192194
> = FnDefination<A, R, K, I, P, Er, WithSeed<RV, U>, O>;
193195

194-
/** What `.with` returns: the same callable, context baked in. */
196+
/** What `.with` returns: the same callable, context baked in.
197+
* Re-exported from the package entry so exporting `.with(...)` results
198+
* stays declaration-emit portable under node16. */
195199
export interface BoundCall<A, R, I, Er> {
196200
(...args: CallArgs<A, I>): R;
197201
try(...args: CallArgs<A, I>): TryResult<R, Er>;

packages/experimental/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,10 +103,12 @@ export {
103103
ValidationError,
104104
} from "./error";
105105
export type {
106+
BoundCall,
106107
Context,
107108
Fn,
108109
FnDefination,
109110
FnErrors,
111+
FnErrorsOf,
110112
Instance,
111113
OptionType,
112114
ParentContext,
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/**
2+
* Dependent-package fixture: import through the published "better-call"
3+
* entry (package exports), export an e.fn that declares errors and uses
4+
* another error-declaring fn. Under moduleResolution node16, declaration
5+
* emit must name types only via the package entry - see
6+
* `declaration-emit.test.ts` (TS2883 / formerly TS2742).
7+
*/
8+
import { v } from "better-call";
9+
10+
const loadUser = v.fn(
11+
"loadUser",
12+
{
13+
errors: {
14+
not_found: { id: v.string() },
15+
denied: { reason: v.string() },
16+
},
17+
},
18+
(c): { id: string } => {
19+
if (Math.random() > 0.5) throw c.error("not_found", { id: "x" });
20+
return { id: "1" };
21+
},
22+
);
23+
24+
const e = v.fn("auth.", { use: [{ loadUser }] });
25+
26+
export const signInEmail = e.fn(
27+
"sign_in.email",
28+
{
29+
input: { email: v.string(), password: v.string() },
30+
errors: {
31+
invalid_credentials: {},
32+
locked: { until: v.date() },
33+
},
34+
},
35+
async (c) => {
36+
const r = c.loadUser();
37+
if (!r.ok) throw c.error("invalid_credentials");
38+
return { user: r.value };
39+
},
40+
);
41+
42+
export const bound = signInEmail.with({});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"compilerOptions": {
3+
"strict": true,
4+
"declaration": true,
5+
"emitDeclarationOnly": true,
6+
"outDir": "./out",
7+
"module": "Node16",
8+
"moduleResolution": "Node16",
9+
"target": "ESNext",
10+
"skipLibCheck": true,
11+
"lib": ["esnext"]
12+
},
13+
"include": ["./index.ts"]
14+
}

0 commit comments

Comments
 (0)