Skip to content

bun-types: declare S3File.name as string and remove S3File.readable - #46754

Open
robobun wants to merge 1 commit into
mainfrom
robobun/15d0f7b0/s3file-name-string
Open

robobun wants to merge 1 commit into
mainfrom
robobun/15d0f7b0/s3file-name-string

Conversation

@robobun

@robobun robobun commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • S3File.name is declared readonly name?: string (packages/bun-types/s3.d.ts:633). Under strict, new File([await s3file.bytes()], s3file.name) fails: TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
  • S3File.readable is declared (s3.d.ts:621), but "readable" in s3file is false. fetch(url, { method: "POST", body: s3file.readable }) compiles and sends no bytes.

Fix

  • Declare readonly name: string. Delete readable, as Move ReadableStream#{text,bytes,json,blob} to global #20879 did for BunFile. Both came from BunFile in feat(s3) s3 client  #15740.
  • Correct because every S3 file has a string name: the getter falls back to store_path(), which returns the S3 path (src/jsc/webcore_types.rs:420).
  • Verified: test/integration/bun-types/fixture/s3.ts and fetch.ts (10 of 24 cases of bun-types.test.ts fail with the old declarations). No executing test asserts the name.
  • Self-reviewed: 19 concerns raised, 16 addressed. Declined: folding in Bun.embeddedFiles and ServerWebSocket.binaryType?. Each is a separate decision (Notes).

Background

  • S3File is the type of s3.file(path) and of its slice(). At run time it is a Blob whose store holds the S3 path.
  • slice() on a blob of size 0 returns an empty plain Blob (src/runtime/webcore/Blob.rs:1755), not an S3File. This PR only documents that, in the JSDoc of slice().
  • Considered an S3 guard in slice(): a slice of an empty slice would then read from the network.

Downsides

  • Code that reads s3file.readable now fails with TS2339. It gets undefined today. stream() replaces it.
  • file.slice(0, 0).slice().name now compiles as string and is undefined. A mock typed S3File with no name now fails with TS2741.
  • Bun.file("s3://...") and a BunFile | S3File union still give string | undefined.
Notes

No user reported this. It was found while #38364 was in review. The TypeError of #38364 prints new File([await s3file.bytes()], s3file.name, { type: s3file.type }), and its docs and JSDoc use a literal file name because of this declaration. #38364 needs no change. It can use s3file.name once this lands.

Run time, on canary 1.4.4-canary.1+a8fcd319b. No network is needed for name: the endpoint is never dialed.

const s3 = new Bun.S3Client({ endpoint: "http://127.0.0.1:9", accessKeyId: "AK", secretAccessKey: "SK", bucket: "bkt" });
s3.file("folder/k.csv").name;             // "folder/k.csv"
s3.file("folder/k.csv").slice(1, 3).name; // "folder/k.csv"
s3.file("").name;                         // ""
s3.file("s3://bkt2/key").name;            // "bkt2/key"
"readable" in s3.file("k");               // false
s3.file("k").slice(0, 0);                 // [object S3File], size 0, name "k"
s3.file("k").slice(0, 0).slice();         // [object Blob]: no name, bucket or presign
  • The published Bun.S3File has 20 members. readable is the only one that no S3 file has.
  • 210 file() calls: 21 paths (empty, /, the s3:// forms, non-ASCII, a trailing slash) through 10 forms (an S3Client with a bucket, one without, new S3Client(), Bun.s3, static S3Client.file, each with and without options). 20 calls throw before an S3File exists. The other 190 results and 7 slices of each (none a slice of an empty slice) are 1520 objects. Each is [object S3File] with a string name.
  • 180 reads of name after an operation against a mock endpoint (exists, stat, text, bytes, json, stream, write, writer().end(), delete and 11 more, for a present key, a 404 and a 403): each is a string. A read works on a copy of the blob (S3BlobDownloadTask::init calls Blob::dupe).
  • readable: for await (const chunk of f.readable) throws a TypeError. await new Response(f.readable).text() is "". fetch(url, { method: "POST", body: f.readable }) sends content-length: 0. With f.stream() the server receives the 11 bytes of the object.

The slice() exception. Blob::slice returns Blob::init_empty when the size field of the blob is 0. The size field of an S3 blob is 0 in two cases:

  • The blob is a zero-length slice: slice(n, n), or slice(a, b) with a > b.
  • resolve_size() ran on the live blob (Blob.rs:2067 sets the size of an S3 blob to 0). Examples: structuredClone(f), postMessage(f), expect(f).toHaveLength(0). After one of these, f.slice(1, 3) is an empty plain Blob. s3: leave the size of an S3 blob unknown in resolve_size #46653 changes that arm of resolve_size(). It does not change the zero-length slice.

The result has no name, no bucket and no presign, so slice(): S3File was already wrong for it. With this PR, new File([await hole.bytes()], hole.name) compiles for it and makes a File named "undefined". A search finds no open issue for the shortcut. A guard for S3 would keep the store, and then hole.text() reads from the network as f.slice(0, 0).text() does today (Range: bytes=0-0, #43929).

The setter. name has a setter at run time (setter: true in Blob.bind.ts, Blob::set_name). A string renames the file. undefined and null are returned as given. The declarations do not publish the setter: name is readonly here, as on File and BunFile. A direct write is TS2540. A write through Object.assign, Reflect.set or an alias typed { name?: string } compiles. This PR does not change the setter.

Not in this PR. Each is a separate decision.

  • On the S3 surface: lastModified is present at run time and not declared. The size JSDoc at s3.d.ts:479-501 has no member under it. NetworkSink.stat (s3.d.ts:98) is declared and no sink has it. Since bindgen: move the stream sinks to a schema and delete generate-jssink.ts #46641, bun-types.test.ts lists it as a known difference. slice(): S3File has the exception above.
  • Bun.embeddedFiles is ReadonlyArray<Blob> (bun.d.ts:4897), so blob.name in the snippets of docs/bundler/executables.mdx gives TS2339. It needs a choice of element type and a docs edit.
  • Bun.file(path).name is always a string and stays string | undefined. BunFile is also the type of Bun.file(fd) and Bun.stdin, whose name is undefined. BunFile should extend Web File #16850 asks for BunFile to extend File.
  • ServerWebSocket.binaryType? (serve.d.ts:315) is "nodebuffer" on a new socket. A change edits two *.test.ts files.
  • markdown.CodeBlockMeta.language? (bun.d.ts:1757) is set on every meta that the code callback gets.

History. #15740 declared interface S3File extends BunFile with readonly name?: string and readonly readable: ReadableStream, both as BunFile had them. e532456 made S3File extend Blob. #18024 moved the interface to s3.d.ts. #20879 turned BunFile.readable into a comment. #42719 is an earlier one-token declaration fix with no issue behind it.

Type-level effect, TypeScript 6.0.2.

declare const f: Bun.S3File;
const s: string = f.name;               // before: TS2322. after: ok
f.name!; f.name ?? "x"; f.name?.length; // ok before and after
f.readable;                             // before: ok. after: TS2339
declare const rest: Omit<Bun.S3File, "name">;
const mock: Bun.S3File = rest;          // before: ok. after: TS2741

typescript-eslint 8.72.0 now reports f.name! (no-unnecessary-type-assertion) and f.name ?? x (no-unnecessary-condition). A // @ts-expect-error that only covered the undefined now fails with TS2578.

Checks run.

  • bun test test/integration/bun-types/bun-types.test.ts with canary a8fcd31: 24 of 24 pass. With the old declarations the fixtures give s3.ts:10:25 TS2554, s3.ts:11:34 TS2345 and fetch.ts:19:1 TS2578 in 10 cases: with and without lib.dom.d.ts, with TypeScript latest, and with TypeScript 7.1.
  • fetch.ts keeps its 389 lines. bun-types.test.ts expects diagnostics at fetch.ts:25, :33 and :168, so the @ts-expect-error line takes the place of a blank line.
  • bun x tsc --noEmit -p for scripts/build, scripts, src/js (after bun run build:types), src and src/runtime/bake: no errors.
  • tsc --noEmit in test/: the output is identical with and without this change.
  • The check of the published types against the bindgen schemas passes. The schema types Blob.prototype.name as b.any, so it cannot see this token.

The bun-types files are not part of the binary, so there is no run-time or size cost.

Two members of `interface S3File` did not match the run time. #15740
took both from BunFile.

`name` was `name?: string`. The name of an S3File is always a string:
the path given to file(). `new File([bytes], s3file.name)` failed under
strict with TS2345.

`readable` was declared, and no S3File has it: `"readable" in s3file`
is false. Code that reads it compiles and gets `undefined`. The
declaration is gone, so that code now fails with TS2339. `stream()`
replaces it. #20879 removed the same member from BunFile.

One case stays as it is: slice() on a blob whose size is 0 returns an
empty plain Blob, not an S3File. That Blob has no name, bucket or
presign. The JSDoc of slice() now says so.

BunFile.name stays optional: a file opened by file descriptor has no
name.
@robobun
robobun requested a review from alii as a code owner October 24, 2026 13:54
@robobun

robobun commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:49 AM PT - Oct 24th, 2026

✅ @robobun, your commit 31290b90967c6c023c294449e348ce1879cafa82 passed in Build #132055! 🎉


🧪   To try this PR locally:

bunx bun-pr 46754

That installs a local version of the PR into your bun-46754 executable, so you can run:

bun-46754 --bun

@robobun

robobun commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. The checks that test this diff pass: TypeScript types, Lint JavaScript and Format.

Buildkite (build 132055, state at 17:15 UTC on Oct 24): 179 of 181 jobs pass and no test is red. The two jobs left are darwin x64 - test-bun. No agent has taken them, and the same is true for the other builds that run at this time. This PR changes one .d.ts file and two type fixtures, so it cannot change the result of those lanes.

How to reproduce (a release build of main, for example canary 1.4.4-canary.1+a8fcd319b):

  1. Type level, with strict and the declarations of main:
    declare const f: Bun.S3File;
    new File([await f.bytes()], f.name); // TS2345
    f.readable;                          // no error
  2. Run time, no network:
    const f = new Bun.S3Client({ endpoint: "http://127.0.0.1:9", accessKeyId: "AK", secretAccessKey: "SK", bucket: "bkt" }).file("folder/k.csv");
    typeof f.name;   // "string"
    "readable" in f; // false
  3. bun test test/integration/bun-types/bun-types.test.ts: with the fixtures of this PR and the declarations of main, 10 of 24 cases fail (s3.ts:10:25 TS2554, s3.ts:11:34 TS2345, fetch.ts:19:1 TS2578). With this PR, all 24 pass.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline finding, I also swept docs/ and packages/bun-types/ for remaining references to S3File.readable — none exist outside the fixture now marked @ ts-expect-error, so the removal leaves no stale JSDoc or docs behind. The @ ts-expect-error line in fetch.ts replaces a blank line, so the diagnostics pinned at fetch.ts:25/:33/:168 in bun-types.test.ts keep their line numbers.

Extended reasoning...

The change edits only TypeScript declarations in packages/bun-types/s3.d.ts (drops readable, tightens name? to name, extends the slice() JSDoc) plus the two type-check fixtures; no runtime or security-sensitive surface is touched. The inline finding on the name: string declaration versus the empty-slice Blob case is the open question for a human, and the ruled-out items above are the only extra checks worth recording.

Comment on lines 620 to 625
* }
* ```
*/
readonly readable: ReadableStream<Uint8Array<ArrayBuffer>>;
stream(): ReadableStream<Uint8Array<ArrayBuffer>>;

/**

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (optional) Users who slice a zero-size S3 slice now get name typed string while the runtime returns undefined, so code TypeScript accepts throws or builds a File named "undefined". slice() on a blob whose size is 0 returns a plain empty Blob with no store (src/runtime/webcore/Blob.rs:1755), and its name getter yields undefined (Blob.rs:1845), yet the overloads at s3.d.ts:535-537 still return S3File with readonly name: string (s3.d.ts:634). Fix: make the declaration true on that path too, e.g. have the zero-size arm of Blob::slice carry the parent's name (and S3 store) into the result, or type the overloads so the empty-slice result is not S3File; the JSDoc note alone leaves the type lying. The same hole opens after resolve_size() zeroes a live S3 blob (Blob.rs:2067).

Why this was flagged

A caller does const hole = s3.file("k").slice(0, 0).slice() (or slices an S3 blob after something called resolve_size() on it, Blob.rs:2067, which sets an S3 blob's size to 0). Blob::slice at src/runtime/webcore/Blob.rs:1755 sees self.size.get() == 0 and returns Blob::init_empty, a plain Blob with no store. Its get_name (Blob.rs:1842-1846) finds no name and no store_path, so it returns JSValue::UNDEFINED. The declarations say slice(): S3File (s3.d.ts:535-537) and, after this diff, readonly name: string (s3.d.ts:634), so hole.name.length or new File([..], hole.name) compiles with no narrowing. On the base branch name?: string forced a ?? / ! guard, so the undefined was visible to the type checker; after merge the user gets a runtime TypeError or a File named "undefined" that tsc said was impossible. The only safeguard is the new JSDoc sentence at s3.d.ts:510-512, which the compiler does not enforce and which also omits the resolve_size() trigger.

Verification: slice() on an S3 blob whose size is 0 (s3.file("k").slice(0, 0).slice()) yields no name. Blob::slice (src/runtime/webcore/Blob.rs:1755-1760) returns a plain Blob with no store, so get_name (Blob.rs:1842-1846) returns undefined. s3.d.ts:535-537 still declares slice(...): S3File and this PR changes s3.d.ts:634 to readonly name: string, so TypeScript now accepts hole.name.length (runtime TypeError) where the base forced narrowing.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is true, and the PR states it (Downsides, second bullet, and "The slice() exception" under Notes). On canary 1.4.4-canary.1+a8fcd319b:

const hole = s3.file("k").slice(0, 0).slice(); // [object Blob]
hole.name;    // undefined
hole.presign; // undefined

I did not apply either fix here. The reasons:

  • Keep the S3 store in the zero-size arm of Blob::slice. The result is then an S3File of size 0, as file.slice(0, 0) is today. A read of that object goes to the network: await file.slice(0, 0).text() sends Range: bytes=0-0 and returns the first byte of the object (S3: a zero-length slice sends Range: bytes=N-N and reads one byte #43929). Today await hole.text() is "" with no request. So this trades a wrong name for wrong bytes until the zero-length read changes too.
  • Carry only the name. name becomes true and presign stays undefined, so slice(): S3File is still wrong for the value. That arm also drops the name of a File, of a Bun.file() and of a Blob with a name set: new File(["abc"], "a.txt").slice(0, 1).name is "a.txt", and .slice(0, 0).slice().name is undefined. So the change is about Blob::slice for every kind of blob. It needs a native build and run-time tests of its own.
  • Type the result as S3File | Blob. Then file.slice(0, 1024).presign(), .bucket and .name fail for every caller: TS2339: Property 'presign' does not exist on type 'Blob | S3File'.

The JSDoc leaves out the resolve_size() route on purpose. #46653 changes that arm (store::DataTag::S3 => {}), so the JSDoc describes only the part that stays: the slice of a zero-length slice.

The decision for a maintainer: take name: string with this one documented exception, or keep name?: string until the run-time hole is closed. If the second is preferred, I will reduce this PR to the removal of readable.

@coderabbitai

coderabbitai Bot commented Oct 24, 2026

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between 179664e and 31290b9.

Files selected for processing: 3
  • packages/bun-types/s3.d.ts
  • test/integration/bun-types/fixture/fetch.ts
  • test/integration/bun-types/fixture/s3.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review.

Walkthrough

The S3 type declaration now requires S3File.name, no longer declares S3File.readable, and clarifies the documented result of slicing to zero length. Type fixtures check these changes and use stream().

Changes

S3File types and fixtures

Layer / File(s) Summary
S3File contract and fixture checks
packages/bun-types/s3.d.ts, test/integration/bun-types/fixture/fetch.ts, test/integration/bun-types/fixture/s3.ts
The declaration requires S3File.name and removes readable. Its slice documentation states that a zero-length slice produces an empty Blob without name, bucket, or presign. The fixtures check the name type, mark readable as unavailable, construct a File, and iterate over stream().

Priority: Normal

Merge Risk: Minimal · up to 31290

This change makes S3File.name required, removes the readable declaration that does not exist at runtime, and documents the zero-length slice behavior. No concrete merge-blocking risk remains. The documented zero-length slice edge case is a known minor typing limitation.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title names the two main declaration changes: S3File.name becomes string and S3File.readable is removed. Both match the changeset in packages/bun-types/s3.d.ts and the bun-types fixtures. It is sp…
Description check Passed The description follows the template's two required sections. 'What does this PR do?' is covered by the Problem, Fix, and Background sections. 'How did you verify your code works?' is covered by the b…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Title check

Explanation

The title names the two main declaration changes: S3File.name becomes string and S3File.readable is removed. Both match the changeset in packages/bun-types/s3.d.ts and the bun-types fixtures. It is specific and concise, and it fits the 'bun-types:' prefix convention used in this repo.

Full details: Description check

Explanation

The description follows the template's two required sections. 'What does this PR do?' is covered by the Problem, Fix, and Background sections. 'How did you verify your code works?' is covered by the bun-types test run (24 of 24 pass, 10 cases fail on the old declarations), the scripts/build, scripts, src/js, src, and src/runtime/bake type checks, and the test/ tsc comparison. The description is detailed and includes reproduction steps, downsides, and history. It is long, and the Notes block is dense, but the required information is present.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant