Skip to content

Commit 418ff97

Browse files
committed
fix(fs): improve safeReadFile type signatures with proper encoding overloads
- Add function overloads to correctly type return values based on encoding - When encoding is null, return Buffer | undefined - When encoding is specified or default, return string | undefined - Default to 'utf8' encoding for better ergonomics - Add support for defaultValue handling based on return type - Improve JSDoc with clearer examples for string vs buffer usage This resolves TypeScript errors where safeReadFile with encoding: 'utf8' was incorrectly typed as returning Buffer instead of string.
1 parent 1c7291a commit 418ff97

File tree

3 files changed

+143
-28
lines changed

3 files changed

+143
-28
lines changed

src/fs.ts

Lines changed: 94 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,26 @@ const defaultRemoveOptions = objectFreeze({
301301
retryDelay: 200,
302302
})
303303

304+
let _buffer: typeof import('node:buffer') | undefined
305+
/**
306+
* Lazily load the buffer module.
307+
*
308+
* Performs on-demand loading of Node.js buffer module to avoid initialization
309+
* overhead and potential Webpack bundling errors.
310+
*
311+
* @private
312+
* @returns {typeof import('node:buffer')} The buffer module
313+
*/
314+
/*@__NO_SIDE_EFFECTS__*/
315+
function getBuffer() {
316+
if (_buffer === undefined) {
317+
// Use non-'node:' prefixed require to avoid Webpack errors.
318+
319+
_buffer = /*@__PURE__*/ require('node:buffer')
320+
}
321+
return _buffer as typeof import('node:buffer')
322+
}
323+
304324
let _fs: typeof import('fs') | undefined
305325
/**
306326
* Lazily load the fs module to avoid Webpack errors.
@@ -983,8 +1003,8 @@ export async function readJson(
9831003
try {
9841004
content = await fs.promises.readFile(filepath, {
9851005
__proto__: null,
986-
encoding: 'utf8',
9871006
...fsOptions,
1007+
encoding: 'utf8',
9881008
} as unknown as Parameters<typeof fs.promises.readFile>[1] & {
9891009
encoding: string
9901010
})
@@ -1060,8 +1080,8 @@ export function readJsonSync(
10601080
try {
10611081
content = fs.readFileSync(filepath, {
10621082
__proto__: null,
1063-
encoding: 'utf8',
10641083
...fsOptions,
1084+
encoding: 'utf8',
10651085
} as unknown as Parameters<typeof fs.readFileSync>[1] & {
10661086
encoding: string
10671087
})
@@ -1381,74 +1401,133 @@ export function safeMkdirSync(
13811401
* Safely read a file asynchronously, returning undefined on error.
13821402
* Useful when you want to attempt reading a file without handling errors explicitly.
13831403
* Returns undefined for any error (file not found, permission denied, etc.).
1404+
* Defaults to UTF-8 encoding, returning a string unless encoding is explicitly set to null.
13841405
*
13851406
* @param filepath - Path to file
13861407
* @param options - Read options including encoding and default value
1387-
* @returns Promise resolving to file contents, or undefined on error
1408+
* @returns Promise resolving to file contents (string by default), or undefined on error
13881409
*
13891410
* @example
13901411
* ```ts
1391-
* // Try to read a file, get undefined if it doesn't exist
1412+
* // Try to read a file as UTF-8 string (default), get undefined if it doesn't exist
13921413
* const content = await safeReadFile('./optional-config.txt')
13931414
* if (content) {
13941415
* console.log('Config found:', content)
13951416
* }
13961417
*
13971418
* // Read with specific encoding
13981419
* const data = await safeReadFile('./data.txt', { encoding: 'utf8' })
1420+
*
1421+
* // Read as Buffer by setting encoding to null
1422+
* const buffer = await safeReadFile('./binary.dat', { encoding: null })
13991423
* ```
14001424
*/
14011425
/*@__NO_SIDE_EFFECTS__*/
1426+
export async function safeReadFile(
1427+
filepath: PathLike,
1428+
options: SafeReadOptions & { encoding: null },
1429+
): Promise<Buffer | undefined>
1430+
/*@__NO_SIDE_EFFECTS__*/
14021431
export async function safeReadFile(
14031432
filepath: PathLike,
14041433
options?: SafeReadOptions | undefined,
1405-
) {
1406-
const opts = typeof options === 'string' ? { encoding: options } : options
1434+
): Promise<string | undefined>
1435+
/*@__NO_SIDE_EFFECTS__*/
1436+
export async function safeReadFile(
1437+
filepath: PathLike,
1438+
options?: SafeReadOptions | undefined,
1439+
): Promise<string | Buffer | undefined> {
1440+
const opts =
1441+
typeof options === 'string'
1442+
? { __proto__: null, encoding: options }
1443+
: ({ __proto__: null, ...options } as SafeReadOptions)
1444+
const { defaultValue, ...rawReadOpts } = opts as SafeReadOptions
1445+
const readOpts = { __proto__: null, ...rawReadOpts } as ReadOptions
1446+
const { encoding = 'utf8' } = readOpts
1447+
const shouldReturnBuffer = encoding === null
14071448
const fs = getFs()
14081449
try {
14091450
return await fs.promises.readFile(filepath, {
1451+
__proto__: null,
14101452
signal: abortSignal,
1411-
...opts,
1453+
...readOpts,
1454+
encoding,
14121455
} as Abortable)
14131456
} catch {}
1414-
return undefined
1457+
if (defaultValue === undefined) {
1458+
return undefined
1459+
}
1460+
if (shouldReturnBuffer) {
1461+
const { Buffer } = getBuffer()
1462+
return Buffer.isBuffer(defaultValue) ? defaultValue : undefined
1463+
}
1464+
return typeof defaultValue === 'string' ? defaultValue : String(defaultValue)
14151465
}
14161466

14171467
/**
14181468
* Safely read a file synchronously, returning undefined on error.
14191469
* Useful when you want to attempt reading a file without handling errors explicitly.
14201470
* Returns undefined for any error (file not found, permission denied, etc.).
1471+
* Defaults to UTF-8 encoding, returning a string unless encoding is explicitly set to null.
14211472
*
14221473
* @param filepath - Path to file
14231474
* @param options - Read options including encoding and default value
1424-
* @returns File contents, or undefined on error
1475+
* @returns File contents (string by default), or undefined on error
14251476
*
14261477
* @example
14271478
* ```ts
1428-
* // Try to read a config file
1479+
* // Try to read a config file as UTF-8 string (default)
14291480
* const config = safeReadFileSync('./config.txt')
14301481
* if (config) {
14311482
* console.log('Config loaded successfully')
14321483
* }
14331484
*
1434-
* // Read binary file safely
1485+
* // Read with explicit encoding
1486+
* const data = safeReadFileSync('./data.txt', { encoding: 'utf8' })
1487+
*
1488+
* // Read binary file by setting encoding to null
14351489
* const buffer = safeReadFileSync('./image.png', { encoding: null })
14361490
* ```
14371491
*/
14381492
/*@__NO_SIDE_EFFECTS__*/
1493+
export function safeReadFileSync(
1494+
filepath: PathLike,
1495+
options: SafeReadOptions & { encoding: null },
1496+
): Buffer | undefined
1497+
/*@__NO_SIDE_EFFECTS__*/
14391498
export function safeReadFileSync(
14401499
filepath: PathLike,
14411500
options?: SafeReadOptions | undefined,
1442-
) {
1443-
const opts = typeof options === 'string' ? { encoding: options } : options
1501+
): string | undefined
1502+
/*@__NO_SIDE_EFFECTS__*/
1503+
export function safeReadFileSync(
1504+
filepath: PathLike,
1505+
options?: SafeReadOptions | undefined,
1506+
): string | Buffer | undefined {
1507+
const opts =
1508+
typeof options === 'string'
1509+
? { __proto__: null, encoding: options }
1510+
: ({ __proto__: null, ...options } as SafeReadOptions)
1511+
const { defaultValue, ...rawReadOpts } = opts as SafeReadOptions
1512+
const readOpts = { __proto__: null, ...rawReadOpts } as ReadOptions
1513+
const { encoding = 'utf8' } = readOpts
1514+
const shouldReturnBuffer = encoding === null
14441515
const fs = getFs()
14451516
try {
14461517
return fs.readFileSync(filepath, {
14471518
__proto__: null,
1448-
...opts,
1519+
...readOpts,
1520+
encoding,
14491521
} as ObjectEncodingOptions)
14501522
} catch {}
1451-
return undefined
1523+
if (defaultValue === undefined) {
1524+
return undefined
1525+
}
1526+
if (shouldReturnBuffer) {
1527+
const { Buffer } = getBuffer()
1528+
return Buffer.isBuffer(defaultValue) ? defaultValue : undefined
1529+
}
1530+
return typeof defaultValue === 'string' ? defaultValue : String(defaultValue)
14521531
}
14531532

14541533
/**

test/unit/fs-sync.test.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -200,13 +200,13 @@ describe.sequential('fs - Sync Functions', () => {
200200
})
201201

202202
describe('safeReadFileSync', () => {
203-
it('should read existing file', () => {
203+
it('should read existing file as utf8 string by default', () => {
204204
const file = join(testDir, 'safe-read.txt')
205205
writeFileSync(file, 'safe content')
206206

207207
const result = safeReadFileSync(file)
208-
expect(Buffer.isBuffer(result)).toBe(true)
209-
expect(result?.toString()).toBe('safe content')
208+
expect(typeof result).toBe('string')
209+
expect(result).toBe('safe content')
210210
})
211211

212212
it('should return undefined for non-existent files', () => {
@@ -220,8 +220,8 @@ describe.sequential('fs - Sync Functions', () => {
220220
writeFileSync(emptyFile, '')
221221

222222
const result = safeReadFileSync(emptyFile)
223-
expect(Buffer.isBuffer(result)).toBe(true)
224-
expect(result?.toString()).toBe('')
223+
expect(typeof result).toBe('string')
224+
expect(result).toBe('')
225225
})
226226

227227
it('should read files with special characters', () => {
@@ -231,6 +231,16 @@ describe.sequential('fs - Sync Functions', () => {
231231
writeFileSync(file, content)
232232

233233
const result = safeReadFileSync(file)
234+
expect(typeof result).toBe('string')
235+
expect(result).toBe(content)
236+
})
237+
238+
it('should read as buffer when encoding is explicitly null', () => {
239+
const file = join(testDir, 'buffer-read.txt')
240+
const content = 'buffer content'
241+
writeFileSync(file, content)
242+
243+
const result = safeReadFileSync(file, { encoding: null })
234244
expect(Buffer.isBuffer(result)).toBe(true)
235245
expect(result?.toString()).toBe(content)
236246
})
@@ -301,8 +311,8 @@ describe.sequential('fs - Sync Functions', () => {
301311
writeFileSync(file1, 'content1')
302312
writeFileSync(file2, 'content2')
303313

304-
expect(safeReadFileSync(file1)?.toString()).toBe('content1')
305-
expect(safeReadFileSync(file2)?.toString()).toBe('content2')
314+
expect(safeReadFileSync(file1)).toBe('content1')
315+
expect(safeReadFileSync(file2)).toBe('content2')
306316
expect(safeReadFileSync(file3)).toBeUndefined()
307317

308318
expect(safeStatsSync(file1)?.isFile()).toBe(true)

test/unit/fs.test.ts

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -875,7 +875,7 @@ describe('fs', () => {
875875
})
876876

877877
describe('safeReadFile', () => {
878-
it('should read existing file', async () => {
878+
it('should read existing file with explicit encoding', async () => {
879879
await runWithTempDir(async tmpDir => {
880880
const testFile = path.join(tmpDir, 'test.txt')
881881
const testContent = 'test content'
@@ -891,20 +891,33 @@ describe('fs', () => {
891891
expect(result).toBeUndefined()
892892
})
893893

894-
it('should read as buffer when no encoding specified', async () => {
894+
it('should read as utf8 string by default when no encoding specified', async () => {
895+
await runWithTempDir(async tmpDir => {
896+
const testFile = path.join(tmpDir, 'text.txt')
897+
const testContent = 'default encoding test'
898+
await fs.writeFile(testFile, testContent, 'utf8')
899+
900+
const result = await safeReadFile(testFile)
901+
expect(typeof result).toBe('string')
902+
expect(result).toBe(testContent)
903+
}, 'safeReadFile-default-')
904+
})
905+
906+
it('should read as buffer when encoding is explicitly null', async () => {
895907
await runWithTempDir(async tmpDir => {
896908
const testFile = path.join(tmpDir, 'binary.dat')
897909
const testData = Buffer.from([0x01, 0x02, 0x03])
898910
await fs.writeFile(testFile, testData)
899911

900-
const result = await safeReadFile(testFile)
912+
const result = await safeReadFile(testFile, { encoding: null })
901913
expect(Buffer.isBuffer(result)).toBe(true)
914+
expect(result).toEqual(testData)
902915
}, 'safeReadFile-buffer-')
903916
})
904917
})
905918

906919
describe('safeReadFileSync', () => {
907-
it('should read existing file', async () => {
920+
it('should read existing file with explicit encoding', async () => {
908921
await runWithTempDir(async tmpDir => {
909922
const testFile = path.join(tmpDir, 'test.txt')
910923
const testContent = 'test content'
@@ -920,14 +933,27 @@ describe('fs', () => {
920933
expect(result).toBeUndefined()
921934
})
922935

923-
it('should read as buffer when no encoding specified', async () => {
936+
it('should read as utf8 string by default when no encoding specified', async () => {
937+
await runWithTempDir(async tmpDir => {
938+
const testFile = path.join(tmpDir, 'text.txt')
939+
const testContent = 'default encoding test'
940+
await fs.writeFile(testFile, testContent, 'utf8')
941+
942+
const result = safeReadFileSync(testFile)
943+
expect(typeof result).toBe('string')
944+
expect(result).toBe(testContent)
945+
}, 'safeReadFileSync-default-')
946+
})
947+
948+
it('should read as buffer when encoding is explicitly null', async () => {
924949
await runWithTempDir(async tmpDir => {
925950
const testFile = path.join(tmpDir, 'binary.dat')
926951
const testData = Buffer.from([0x01, 0x02, 0x03])
927952
await fs.writeFile(testFile, testData)
928953

929-
const result = safeReadFileSync(testFile)
954+
const result = safeReadFileSync(testFile, { encoding: null })
930955
expect(Buffer.isBuffer(result)).toBe(true)
956+
expect(result).toEqual(testData)
931957
}, 'safeReadFileSync-buffer-')
932958
})
933959
})

0 commit comments

Comments
 (0)