skip to content

After upgrading an Expo app to SDK 57, readAsStringAsync imported from expo-file-system throws at runtime; why, and how do you migrate the code?

level: seniorimportance: should knowfreq 32%

answer

  1. SDK 54 swapped the default export
  2. root exports legacy names as throwing stubs
  3. expo-file-system/legacy still ships
  4. strings with trailing slash become Directory objects
  5. map call by call, then drop legacy

basics

~20 s

Since SDK 54 the root expo-file-system import is the File/Directory/Paths API, and old names like readAsStringAsync are stubs that warn and throw. Import them from expo-file-system/legacy to unblock, then migrate each call, for example to new File(uri).text().

solid answer

~40 s

In SDK 54 `expo-file-system` made the object API its default export and moved the old function API to `expo-file-system/legacy`. The root package still exports functions named `readAsStringAsync`, `writeAsStringAsync`, `deleteAsync` and so on, but they are deprecated stubs that log a warning and throw, and the `documentDirectory` and `cacheDirectory` constants are not exported from the root at all. The quick fix is changing the import to `expo-file-system/legacy`, which still ships in SDK 57. The real migration maps each call: `readAsStringAsync` to `file.text()`, `writeAsStringAsync` to `file.write()`, `getInfoAsync` to `exists` or `info()`, `deleteAsync` to `delete()`, `copyAsync` and `moveAsync` to `copy()` and `move()`, `downloadAsync` to `File.downloadFileAsync`. Both APIs use the same `file://` URIs, so I can migrate module by module.

code

typescript · 8 lines
typescript
// Step 1: the legacy entry point (the root import would throw here)
import * as FileSystem from 'expo-file-system/legacy';

export async function loadNotes(): Promise<string> {
  const uri = FileSystem.documentDirectory + 'notes.txt';
  const info = await FileSystem.getInfoAsync(uri);
  return info.exists ? FileSystem.readAsStringAsync(uri) : '';
}

go deeper

for a junior

Recall that SDK 54 changed the default import and that the old functions now live at expo-file-system/legacy.

for a middle

Explain the mapping from the main legacy functions to File, Directory and Paths methods, and which new calls are synchronous.

for a senior

Plan the migration: unblock with the legacy import, migrate module by module behind helpers, handle throw-instead-of-flag semantics and partial reads, and verify on both platforms.

for a principal

Decide how long the legacy import may live, how to prevent new legacy usage with lint rules, and how SDK upgrades are budgeted so deprecations are absorbed before they become removals.

## What changed and when `expo-file-system` shipped a function-based API for years: string constants `documentDirectory` and `cacheDirectory`, and Promise functions such as `readAsStringAsync(uri)`. A class-based API (`File`, `Directory`, `Paths`) was previewed and, **in SDK 54**, became the package's default export. The old API moved to the **`expo-file-system/legacy`** entry point. To make the break visible rather than silent, the root package still exports functions with the legacy names, but each is a **stub**: it logs a message saying the method imported from `expo-file-system` is deprecated and pointing to the new classes or the legacy import, then throws. The directory constants are simply absent from the root export, so TypeScript reports a missing property and plain JavaScript gets `undefined`, turning `documentDirectory + 'notes.txt'` into a bogus path. ## Step 1: unblock the upgrade Change `import * as FileSystem from 'expo-file-system'` to `import * as FileSystem from 'expo-file-system/legacy'`. The legacy entry point is still part of the SDK 57 package and behaves as before, so the upgrade can ship while the migration proceeds. Both APIs address files by the same `file://` URIs, so `new File(uri)` can wrap a URI produced by legacy code and vice versa. ## Step 2: map the calls | Legacy (`expo-file-system/legacy`) | Object API (`expo-file-system`) | |---|---| | `documentDirectory`, `cacheDirectory` (strings ending in `/`) | `Paths.document`, `Paths.cache` (`Directory` objects) | | `readAsStringAsync(uri)` | `await new File(uri).text()` or `textSync()` | | `readAsStringAsync(uri, { encoding: 'base64' })` | `await file.base64()` | | `writeAsStringAsync(uri, s)` | `file.write(s)` (synchronous) | | `getInfoAsync(uri)` | `file.exists`, `file.size`, `file.info()` | | `deleteAsync(uri, { idempotent: true })` | `if (file.exists) file.delete()` | | `makeDirectoryAsync(uri, { intermediates: true })` | `dir.create({ intermediates: true })` | | `readDirectoryAsync(uri)` | `dir.list()` | | `copyAsync` / `moveAsync({ from, to })` | `await file.copy(dest)` / `await file.move(dest)` | | `downloadAsync(url, uri)` | `await File.downloadFileAsync(url, dest)` | | `getFreeDiskStorageAsync()` | `Paths.availableDiskSpace` | ## Semantic differences that cause bugs - **Sync versus async moves around.** `write()`, `delete()`, `create()` and `list()` are synchronous; `text()`, `copy()` and `move()` return Promises. - **Errors instead of flags.** Legacy `deleteAsync` had an `idempotent` option; the new `delete()` takes none and throws when the target is missing, so guard with `exists` or `try/catch`. - **Paths are objects.** String concatenation with a trailing slash becomes constructor segments: `new File(Paths.document, 'notes', 'a.txt')`. - **Partial reads.** Legacy `readAsStringAsync` accepts `position` and `length`; the object API does that through `file.open()`, setting `offset` and calling `readBytes(length)`. - **Resumable downloads and uploads.** `createDownloadResumable` maps to `File.createDownloadTask` (SDK 56+), `uploadAsync` to `file.upload()` or `expo/fetch`. - **Android Storage Access Framework.** The legacy `StorageAccessFramework` namespace maps to `Directory.pickDirectoryAsync()`, which returns a directory backed by a `content://` URI on Android. ## Step 3: finish and verify 1. Migrate one module at a time behind small helper functions, so call sites change once. 2. Update tests: since SDK 56 the package ships in-memory Jest mocks for the class API, so tests exercise create, write, read, copy and delete without hand-written mocks. 3. Search the codebase for `expo-file-system/legacy` and remove the last import when it is gone. 4. Verify on both platforms, because behaviour such as partial downloads differs between iOS and Android.

  • Can legacy and new code touch the same file during a gradual migration?
    Yes. Both address files by the same `file://` URIs, so `new File(uri)` wraps a URI built by legacy code, and `file.uri` can be passed to a legacy function. Migrating module by module is safe as long as each file has one writer at a time.
  • Why does a JavaScript file that uses FileSystem.documentDirectory produce paths starting with 'undefined' after the upgrade?
    The root `expo-file-system` export no longer contains the `documentDirectory` constant, so it evaluates to `undefined` and string concatenation yields 'undefinednotes.txt'. TypeScript catches it as a missing property; plain JavaScript does not. Import it from `expo-file-system/legacy` or use `Paths.document`.

saying these in an interview costs you the question

  • readAsStringAsync was removed entirely from the package in SDK 54.
  • The root import still runs the legacy functions, just with a warning.
  • Legacy and new APIs use incompatible URI formats, so migration must be all at once.
  • new File(uri).delete() ignores a missing file like deleteAsync with idempotent.
  • Upgrading requires a native data migration of the documents directory.