fix(error-handling): prevent [object Object] from appearing in error messages

Improve error text extraction utilities to never return "[object Object]"
when displaying error messages to users. The fix adds more robust fallback
mechanisms including:

- Check for message, name, statusText properties before calling toString()
- Detect "[object Object]" result and fallback to JSON.stringify()
- Provide meaningful fallback messages when all extraction methods fail

Fixes #5790
This commit is contained in:
Johannes Millan 2026-01-02 15:30:43 +01:00
parent 3a5cddd8e8
commit e571d6e3bc
5 changed files with 499 additions and 39 deletions

View file

@ -56,20 +56,48 @@ function _handleError(
}
}
const OBJECT_OBJECT_STR = '[object Object]';
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
function _getErrorStr(e: unknown): string {
if (typeof e === 'string') {
return e;
}
if (e == null) {
return 'Unknown error';
}
// Check for message property first (standard Error and custom errors)
if (typeof (e as any).message === 'string' && (e as any).message) {
return (e as any).message;
}
if (e instanceof Error) {
return e.toString();
}
if (typeof e === 'object' && e !== null) {
// Check for name property
if (typeof (e as any).name === 'string' && (e as any).name) {
return (e as any).name;
}
if (typeof e === 'object') {
try {
return JSON.stringify(e);
} catch (err) {
return String(e);
const jsonStr = JSON.stringify(e);
if (jsonStr && jsonStr !== '{}') {
return jsonStr;
}
} catch {
// Circular reference - fall through
}
// Try toString but check for [object Object]
const str = String(e);
if (str && str !== OBJECT_OBJECT_STR) {
return str;
}
}
return String(e);
return 'Unknown error (unable to extract message)';
}

View file

@ -0,0 +1,150 @@
import { getSyncErrorStr } from './get-sync-error-str';
import { HANDLED_ERROR_PROP_STR } from '../../app.constants';
describe('getSyncErrorStr', () => {
it('should return string errors directly', () => {
expect(getSyncErrorStr('sync failed')).toBe('sync failed');
});
it('should handle null', () => {
expect(getSyncErrorStr(null)).toBe('Unknown sync error');
});
it('should handle undefined', () => {
expect(getSyncErrorStr(undefined)).toBe('Unknown sync error');
});
it('should extract message from Error instances', () => {
const err = new Error('sync error message');
expect(getSyncErrorStr(err)).toBe('sync error message');
});
it('should handle HANDLED_ERROR_PROP_STR', () => {
const err = { [HANDLED_ERROR_PROP_STR]: 'handled sync error' };
expect(getSyncErrorStr(err)).toBe('handled sync error');
});
it('should extract response.data string (Axios pattern)', () => {
const err = { response: { data: 'server error response' } };
expect(getSyncErrorStr(err)).toBe('server error response');
});
it('should extract response.data.message (nested API error)', () => {
const err = { response: { data: { message: 'API error message' } } };
expect(getSyncErrorStr(err)).toBe('API error message');
});
it('should extract name property', () => {
const err = { name: 'SyncError' };
expect(getSyncErrorStr(err)).toBe('SyncError');
});
it('should extract statusText for HTTP errors', () => {
const err = { statusText: 'Service Unavailable' };
expect(getSyncErrorStr(err)).toBe('Service Unavailable');
});
it('should never return [object Object]', () => {
const plainObject = { foo: 'bar' };
const result = getSyncErrorStr(plainObject);
expect(result).not.toBe('[object Object]');
});
it('should JSON.stringify objects without standard error properties', () => {
const err = { code: 'NETWORK_ERROR', retry: true };
const result = getSyncErrorStr(err);
expect(result).toContain('code');
expect(result).toContain('NETWORK_ERROR');
});
it('should handle empty objects', () => {
const result = getSyncErrorStr({});
expect(result).toBe('Unknown sync error (unable to extract message)');
});
it('should prioritize HANDLED_ERROR_PROP_STR over message', () => {
const err = {
[HANDLED_ERROR_PROP_STR]: 'handled',
message: 'regular message',
};
expect(getSyncErrorStr(err)).toBe('handled');
});
it('should truncate long error messages', () => {
const longMessage = 'x'.repeat(500);
const err = { message: longMessage };
const result = getSyncErrorStr(err);
expect(result.length).toBeLessThanOrEqual(403); // 400 + '...'
});
it('should handle objects with custom toString', () => {
const err = {
toString: () => 'custom sync error',
};
expect(getSyncErrorStr(err)).toBe('custom sync error');
});
it('should prefer message over response.data', () => {
const err = {
message: 'direct message',
response: { data: 'response data' },
};
expect(getSyncErrorStr(err)).toBe('direct message');
});
it('should handle circular reference objects gracefully', () => {
const err: any = { message: null, data: {} };
err.data.self = err; // circular reference
const result = getSyncErrorStr(err);
expect(result).not.toBe('[object Object]');
expect(result).toBe('Unknown sync error (unable to extract message)');
});
it('should handle arrays', () => {
const result = getSyncErrorStr(['sync error 1', 'sync error 2']);
expect(result).not.toBe('[object Object]');
expect(result).toContain('sync error');
});
it('should handle numbers', () => {
expect(getSyncErrorStr(503)).toBe('503');
});
it('should handle objects where toString throws', () => {
const err = {
toString: () => {
throw new Error('toString failed');
},
};
const result = getSyncErrorStr(err);
expect(result).not.toBe('[object Object]');
});
it('should handle empty string message', () => {
const err = { message: '' };
const result = getSyncErrorStr(err);
// Empty message falls through to JSON.stringify
expect(result).not.toBe('[object Object]');
expect(result).toContain('message');
});
it('should handle WebDAV-style errors', () => {
const err = {
status: 423,
statusText: 'Locked',
response: { data: 'Resource is locked by another process' },
};
expect(getSyncErrorStr(err)).toBe('Resource is locked by another process');
});
it('should handle Dropbox-style API errors', () => {
const err = {
error: {
error_summary: 'path/not_found/...',
error: { tag: 'path', path: { tag: 'not_found' } },
},
};
const result = getSyncErrorStr(err);
expect(result).not.toBe('[object Object]');
});
});

View file

@ -1,28 +1,76 @@
import { truncate } from '../../util/truncate';
import { HANDLED_ERROR_PROP_STR } from '../../app.constants';
import { isObject } from '../../util/is-object';
// ugly little helper to make sure we get the most information out of it for the user
const OBJECT_OBJECT_STR = '[object Object]';
// Helper to extract error string from various error shapes
export const getSyncErrorStr = (err: unknown): string => {
let errorAsString: string =
err && (err as any)?.toString ? (err as any).toString() : '???';
// Check if error has a message property (most Error objects do)
if (err && typeof (err as any)?.message === 'string') {
errorAsString = (err as any).message as string;
// Handle string errors directly
if (typeof err === 'string') {
return truncate(err, 400);
}
if (err && typeof (err as any)?.response?.data === 'string') {
errorAsString = (err as any)?.response?.data as string;
// Handle null/undefined
if (err == null) {
return 'Unknown sync error';
}
if (
errorAsString === '[object Object]' &&
err &&
(err as any)[HANDLED_ERROR_PROP_STR]
) {
errorAsString = (err as any)[HANDLED_ERROR_PROP_STR] as string;
const errAny = err as any;
// Check for handled error marker first (highest priority)
if (errAny[HANDLED_ERROR_PROP_STR]) {
return truncate(String(errAny[HANDLED_ERROR_PROP_STR]), 400);
}
// Increased from 150 to 400 to show more context, especially for HTTP errors
return truncate(errorAsString.toString(), 400);
// Check message property (standard Error objects)
if (typeof errAny.message === 'string' && errAny.message) {
return truncate(errAny.message, 400);
}
// Check response.data (Axios-style errors)
if (typeof errAny.response?.data === 'string' && errAny.response.data) {
return truncate(errAny.response.data, 400);
}
// Check response.data.message (nested API error)
if (typeof errAny.response?.data?.message === 'string') {
return truncate(errAny.response.data.message, 400);
}
// Check for name property
if (typeof errAny.name === 'string' && errAny.name) {
return truncate(errAny.name, 400);
}
// Check for statusText (HTTP errors)
if (typeof errAny.statusText === 'string' && errAny.statusText) {
return truncate(errAny.statusText, 400);
}
// Try toString() but check for [object Object]
if (typeof errAny.toString === 'function') {
try {
const str = errAny.toString();
if (str && str !== OBJECT_OBJECT_STR) {
return truncate(str, 400);
}
} catch {
// toString() threw - fall through to JSON.stringify
}
}
// Try JSON.stringify as last resort
if (isObject(err)) {
try {
const jsonStr = JSON.stringify(err);
if (jsonStr && jsonStr !== '{}') {
return truncate(jsonStr, 400);
}
} catch {
// Circular reference or other JSON error - fall through
}
}
return 'Unknown sync error (unable to extract message)';
};

View file

@ -0,0 +1,178 @@
import { getErrorTxt } from './get-error-text';
import { HANDLED_ERROR_PROP_STR } from '../app.constants';
describe('getErrorTxt', () => {
it('should return string errors directly', () => {
expect(getErrorTxt('simple error')).toBe('simple error');
});
it('should handle null', () => {
expect(getErrorTxt(null)).toBe('Unknown error (null/undefined)');
});
it('should handle undefined', () => {
expect(getErrorTxt(undefined)).toBe('Unknown error (null/undefined)');
});
it('should extract message from Error instances', () => {
const err = new Error('test error message');
expect(getErrorTxt(err)).toBe('test error message');
});
it('should extract message from plain objects with message property', () => {
expect(getErrorTxt({ message: 'object error' })).toBe('object error');
});
it('should handle HANDLED_ERROR_PROP_STR', () => {
const err = { [HANDLED_ERROR_PROP_STR]: 'handled error message' };
expect(getErrorTxt(err)).toBe('handled error message');
});
it('should extract nested error.message (HttpErrorResponse pattern)', () => {
const err = { error: { message: 'nested error message' } };
expect(getErrorTxt(err)).toBe('nested error message');
});
it('should extract error.name when message is not available', () => {
const err = { error: { name: 'ValidationError' } };
expect(getErrorTxt(err)).toBe('ValidationError');
});
it('should extract deeply nested error.error.message', () => {
const err = { error: { error: { message: 'deep nested message' } } };
expect(getErrorTxt(err)).toBe('deep nested message');
});
it('should extract name property when message is not available', () => {
const err = { name: 'CustomError' };
expect(getErrorTxt(err)).toBe('CustomError');
});
it('should extract statusText for HTTP errors', () => {
const err = { statusText: 'Not Found' };
expect(getErrorTxt(err)).toBe('Not Found');
});
it('should never return [object Object]', () => {
const plainObject = { foo: 'bar' };
const result = getErrorTxt(plainObject);
expect(result).not.toBe('[object Object]');
expect(result).toContain('foo');
});
it('should JSON.stringify objects without standard error properties', () => {
const err = { code: 500, details: 'server error' };
const result = getErrorTxt(err);
expect(result).toContain('code');
expect(result).toContain('500');
});
it('should handle empty objects', () => {
const result = getErrorTxt({});
expect(result).toBe('Unknown error (unable to extract message)');
});
it('should prioritize HANDLED_ERROR_PROP_STR over message', () => {
const err = {
[HANDLED_ERROR_PROP_STR]: 'handled',
message: 'regular message',
};
expect(getErrorTxt(err)).toBe('handled');
});
it('should handle TypeError instances', () => {
const err = new TypeError('Cannot read property of undefined');
expect(getErrorTxt(err)).toBe('Cannot read property of undefined');
});
it('should handle objects with custom toString that does not return [object Object]', () => {
const err = {
toString: () => 'custom error string',
};
expect(getErrorTxt(err)).toBe('custom error string');
});
it('should truncate long JSON strings', () => {
const longValue = 'x'.repeat(300);
const err = { data: longValue };
const result = getErrorTxt(err);
expect(result.length).toBeLessThanOrEqual(203); // 200 + '...'
});
it('should handle circular reference objects gracefully', () => {
const err: any = { message: null, data: {} };
err.data.self = err; // circular reference
const result = getErrorTxt(err);
// Should not throw and should not return [object Object]
expect(result).not.toBe('[object Object]');
expect(result).toBe('Unknown error (unable to extract message)');
});
it('should handle arrays', () => {
const result = getErrorTxt(['error1', 'error2']);
expect(result).not.toBe('[object Object]');
expect(result).toContain('error1');
});
it('should handle numbers', () => {
expect(getErrorTxt(404)).toBe('404');
});
it('should handle booleans', () => {
expect(getErrorTxt(false)).toBe('false');
});
it('should handle objects where toString throws', () => {
const err = {
toString: () => {
throw new Error('toString failed');
},
};
const result = getErrorTxt(err);
expect(result).not.toBe('[object Object]');
});
it('should handle empty string message', () => {
const err = { message: '' };
const result = getErrorTxt(err);
// Empty message falls through to JSON.stringify
expect(result).not.toBe('[object Object]');
expect(result).toContain('message');
});
it('should handle RangeError instances', () => {
const err = new RangeError('Maximum call stack size exceeded');
expect(getErrorTxt(err)).toBe('Maximum call stack size exceeded');
});
it('should handle SyntaxError instances', () => {
const err = new SyntaxError('Unexpected token');
expect(getErrorTxt(err)).toBe('Unexpected token');
});
it('should handle DOMException-like objects', () => {
const err = { name: 'NotFoundError', message: 'Node was not found' };
expect(getErrorTxt(err)).toBe('Node was not found');
});
it('should handle HTTP response error objects', () => {
const err = {
status: 500,
statusText: 'Internal Server Error',
error: { message: 'Database connection failed' },
};
expect(getErrorTxt(err)).toBe('Database connection failed');
});
it('should handle Axios-style error with response.data.error', () => {
const err = {
response: {
status: 401,
data: { error: 'Unauthorized access' },
},
};
const result = getErrorTxt(err);
// Should JSON.stringify since no direct message property
expect(result).toContain('Unauthorized');
});
});

View file

@ -1,24 +1,80 @@
import { isObject } from './is-object';
import { HANDLED_ERROR_PROP_STR } from '../app.constants';
const OBJECT_OBJECT_STR = '[object Object]';
export const getErrorTxt = (err: unknown): string => {
if (err && isObject((err as any).error)) {
return (
(err as any).error.message ||
(err as any).error.name ||
// for ngx translate...
(isObject((err as any).error.error)
? (err as any).error.error.toString()
: (err as any).error) ||
(err as any).error
);
} else if (err && (err as any)[HANDLED_ERROR_PROP_STR]) {
return (err as any)[HANDLED_ERROR_PROP_STR];
} else if (err && (err as any).toString) {
return (err as any).toString();
} else if (typeof err === 'string') {
// Handle string errors directly
if (typeof err === 'string') {
return err;
} else {
return 'Unknown getErrorTxt error';
}
// Handle null/undefined
if (err == null) {
return 'Unknown error (null/undefined)';
}
const errAny = err as any;
// Check for handled error marker first
if (errAny[HANDLED_ERROR_PROP_STR]) {
return errAny[HANDLED_ERROR_PROP_STR];
}
// Check direct message property (standard Error objects)
if (typeof errAny.message === 'string' && errAny.message) {
return errAny.message;
}
// Check nested error.message (HttpErrorResponse pattern)
if (isObject(errAny.error)) {
if (typeof errAny.error.message === 'string' && errAny.error.message) {
return errAny.error.message;
}
if (typeof errAny.error.name === 'string' && errAny.error.name) {
return errAny.error.name;
}
// Handle deeper nesting (ngx-translate pattern)
if (isObject(errAny.error.error)) {
if (typeof errAny.error.error.message === 'string') {
return errAny.error.error.message;
}
}
}
// Check for name property (some Error subclasses)
if (typeof errAny.name === 'string' && errAny.name) {
return errAny.name;
}
// Check for statusText (HTTP errors)
if (typeof errAny.statusText === 'string' && errAny.statusText) {
return errAny.statusText;
}
// Try toString() but check for [object Object]
if (typeof errAny.toString === 'function') {
try {
const str = errAny.toString();
if (str && str !== OBJECT_OBJECT_STR) {
return str;
}
} catch {
// toString() threw - fall through to JSON.stringify
}
}
// Try JSON.stringify as last resort for objects
if (isObject(err)) {
try {
const jsonStr = JSON.stringify(err);
if (jsonStr && jsonStr !== '{}') {
return jsonStr.length > 200 ? jsonStr.substring(0, 200) + '...' : jsonStr;
}
} catch {
// Circular reference or other JSON error - fall through
}
}
return 'Unknown error (unable to extract message)';
};