Skip to content

Commit 1b98977

Browse files
feat: parseNumberFromString
1 parent 1b00a1f commit 1b98977

2 files changed

Lines changed: 323 additions & 0 deletions

File tree

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
import { describe, expect, it, test } from 'vitest'
2+
import { parseNumberFromString } from './parseNumberFromString'
3+
4+
const expectFailure = () =>
5+
expect.objectContaining({
6+
tag: 'failure',
7+
})
8+
9+
describe('numberFromString', () => {
10+
it('should parse natural numbers', () => {
11+
expect(parseNumberFromString('123')).toEqual({
12+
tag: 'success',
13+
value: 123,
14+
})
15+
})
16+
it('should parse integers', () => {
17+
expect(parseNumberFromString('123')).toEqual({
18+
tag: 'success',
19+
value: 123,
20+
})
21+
expect(parseNumberFromString('-123')).toEqual({
22+
tag: 'success',
23+
value: -123,
24+
})
25+
})
26+
it('should parse floating numbers', () => {
27+
const result = parseNumberFromString('1.5')
28+
const epsilon = 1e-3
29+
if (result.tag === 'failure') {
30+
throw new Error('Expected success')
31+
}
32+
expect(result.value).toBeLessThanOrEqual(1.5 + epsilon)
33+
expect(result.value).toBeGreaterThanOrEqual(1.5 - epsilon)
34+
})
35+
it('should not return infinity for large numbers', () => {
36+
const googolStr = `1${new Array(1000).fill(0).join('')}`
37+
expect(parseNumberFromString(googolStr)).toEqual(expectFailure())
38+
})
39+
it('should not return infinity for small numbers', () => {
40+
const googolStr = `-1${new Array(1000).fill(0).join('')}`
41+
expect(parseNumberFromString(googolStr)).toEqual(expectFailure())
42+
})
43+
it('parses binary values', () => {
44+
expect(parseNumberFromString('0b1')).toEqual({
45+
tag: 'success',
46+
value: 1,
47+
})
48+
expect(parseNumberFromString('0b00')).toEqual({
49+
tag: 'success',
50+
value: 0,
51+
})
52+
expect(parseNumberFromString('0b0')).toEqual({
53+
tag: 'success',
54+
value: 0,
55+
})
56+
expect(parseNumberFromString('0b10')).toEqual({
57+
tag: 'success',
58+
value: 2,
59+
})
60+
})
61+
it('parses hexadecimal values', () => {
62+
expect(parseNumberFromString('0xFF')).toEqual({
63+
tag: 'success',
64+
value: 255,
65+
})
66+
expect(parseNumberFromString('0x00')).toEqual({
67+
tag: 'success',
68+
value: 0,
69+
})
70+
expect(parseNumberFromString('0x0')).toEqual({
71+
tag: 'success',
72+
value: 0,
73+
})
74+
expect(parseNumberFromString('0x80')).toEqual({
75+
tag: 'success',
76+
value: 128,
77+
})
78+
})
79+
it('parses octal values', () => {
80+
expect(parseNumberFromString('0o755')).toEqual({
81+
tag: 'success',
82+
value: 493,
83+
})
84+
expect(parseNumberFromString('0o00')).toEqual({
85+
tag: 'success',
86+
value: 0,
87+
})
88+
expect(parseNumberFromString('0o0')).toEqual({
89+
tag: 'success',
90+
value: 0,
91+
})
92+
})
93+
describe('scientific notation', () => {
94+
it('parses scientific notation', () => {
95+
test('upper and lower case', () => {
96+
expect(parseNumberFromString('1e2')).toEqual({
97+
tag: 'success',
98+
value: 100,
99+
})
100+
expect(parseNumberFromString('1E2')).toEqual({
101+
tag: 'success',
102+
value: 100,
103+
})
104+
expect(parseNumberFromString('1e-2')).toEqual({
105+
tag: 'success',
106+
value: 0.01,
107+
})
108+
expect(parseNumberFromString('1E-2')).toEqual({
109+
tag: 'success',
110+
value: 0.01,
111+
})
112+
})
113+
test('positive exponent', () => {
114+
expect(parseNumberFromString('1e2')).toEqual({
115+
tag: 'success',
116+
value: 100,
117+
})
118+
expect(parseNumberFromString('1e+2')).toEqual({
119+
tag: 'success',
120+
value: 100,
121+
})
122+
})
123+
test('negative exponent', () => {
124+
expect(parseNumberFromString('1e-2')).toEqual({
125+
tag: 'success',
126+
value: 0.01,
127+
})
128+
expect(parseNumberFromString('1e-0')).toEqual({
129+
tag: 'success',
130+
value: 1,
131+
})
132+
expect(parseNumberFromString('1e+0')).toEqual({
133+
tag: 'success',
134+
value: 1,
135+
})
136+
})
137+
test('zero exponent', () => {
138+
expect(parseNumberFromString('1e0')).toEqual({
139+
tag: 'success',
140+
value: 1,
141+
})
142+
expect(parseNumberFromString('1E0')).toEqual({
143+
tag: 'success',
144+
value: 1,
145+
})
146+
})
147+
test('zero base', () => {
148+
expect(parseNumberFromString('0e0')).toEqual({
149+
tag: 'success',
150+
value: 0,
151+
})
152+
expect(parseNumberFromString('0E0')).toEqual({
153+
tag: 'success',
154+
value: 0,
155+
})
156+
})
157+
test('zero base with exponent', () => {
158+
expect(parseNumberFromString('0e2')).toEqual({
159+
tag: 'success',
160+
value: 0,
161+
})
162+
expect(parseNumberFromString('0E2')).toEqual({
163+
tag: 'success',
164+
value: 0,
165+
})
166+
})
167+
test('zero base with negative exponent', () => {
168+
expect(parseNumberFromString('0e-2')).toEqual({
169+
tag: 'success',
170+
value: 0,
171+
})
172+
expect(parseNumberFromString('0E-2')).toEqual({
173+
tag: 'success',
174+
value: 0,
175+
})
176+
})
177+
test('zero base with positive exponent', () => {
178+
expect(parseNumberFromString('0e+2')).toEqual({
179+
tag: 'success',
180+
value: 0,
181+
})
182+
expect(parseNumberFromString('0E+2')).toEqual({
183+
tag: 'success',
184+
value: 0,
185+
})
186+
})
187+
test('zero base with zero exponent', () => {
188+
expect(parseNumberFromString('0e0')).toEqual({
189+
tag: 'success',
190+
value: 0,
191+
})
192+
expect(parseNumberFromString('0E0')).toEqual({
193+
tag: 'success',
194+
value: 0,
195+
})
196+
})
197+
test('larger number in the exponent', () => {})
198+
test('larger number in the base', () => {
199+
expect(parseNumberFromString('20e0')).toEqual({
200+
tag: 'success',
201+
value: 20,
202+
})
203+
})
204+
test('decimals in the exponent', () => {
205+
expect(parseNumberFromString('1e0.2')).toEqual({
206+
tag: 'success',
207+
value: 1.5848931924611136,
208+
})
209+
expect(parseNumberFromString('1e-0.2')).toEqual({
210+
tag: 'success',
211+
value: 0.6309573444801932,
212+
})
213+
expect(parseNumberFromString('1e+0.2')).toEqual({
214+
tag: 'success',
215+
value: 1.5848931924611136,
216+
})
217+
expect(parseNumberFromString('1E0.2')).toEqual({
218+
tag: 'success',
219+
value: 1.5848931924611136,
220+
})
221+
})
222+
test('decimals in the base', () => {
223+
expect(parseNumberFromString('0.2e0')).toEqual({
224+
tag: 'success',
225+
value: 0.2,
226+
})
227+
})
228+
test('decimals in the base and exponent', () => {
229+
expect(parseNumberFromString('0.2e0.2')).toEqual({
230+
tag: 'success',
231+
value: 0.2,
232+
})
233+
})
234+
})
235+
})
236+
test('empty strings', () => {
237+
expect(parseNumberFromString('')).toEqual(
238+
expect.objectContaining({
239+
tag: 'failure',
240+
}),
241+
)
242+
})
243+
describe('malformatted numbers', () => {
244+
test('string with whitespace', () => {
245+
expect(parseNumberFromString(' ')).toEqual(expectFailure())
246+
expect(parseNumberFromString('\r')).toEqual(expectFailure())
247+
expect(parseNumberFromString('\n')).toEqual(expectFailure())
248+
expect(parseNumberFromString('\n\r ')).toEqual(expectFailure())
249+
})
250+
test('string with number and whitespace', () => {
251+
expect(parseNumberFromString('1 ')).toEqual(expectFailure())
252+
expect(parseNumberFromString(' 1')).toEqual(expectFailure())
253+
expect(parseNumberFromString(' 1 ')).toEqual(expectFailure())
254+
expect(parseNumberFromString('\r 1')).toEqual(expectFailure())
255+
expect(parseNumberFromString('\n 1')).toEqual(expectFailure())
256+
expect(parseNumberFromString('\n\r 1')).toEqual(expectFailure())
257+
})
258+
test('fractions', () => {
259+
expect(parseNumberFromString('1/2')).toEqual(expectFailure())
260+
})
261+
test('text followed by numbers', () => {
262+
expect(parseNumberFromString('hello123')).toEqual(expectFailure())
263+
})
264+
test('numbers followed by text', () => {
265+
expect(parseNumberFromString('123hello')).toEqual(expectFailure())
266+
})
267+
test('numbers in text', () => {
268+
expect(parseNumberFromString('hello123hello')).toEqual(expectFailure())
269+
})
270+
test('text', () => {
271+
expect(parseNumberFromString('hello')).toEqual(expectFailure())
272+
})
273+
})
274+
})
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { failure, Parser, success } from './types'
2+
import { isString } from '../guards'
3+
4+
/**
5+
* Parses a number from a string without any suprises. Strings that describe numbers (without any other characters involved) yield
6+
* `number`. All other combinations of characters yield `undefined`.
7+
*/
8+
const numberFromString = (str: string): number | undefined => {
9+
const parsed = Number(str)
10+
return str !== '' && !hasWhiteSpace(str) && !isNaN(parsed) && isFinite(parsed)
11+
? parsed
12+
: undefined
13+
}
14+
15+
/**
16+
* @param str
17+
* @returns `true` if any character is whitespace.
18+
*/
19+
const hasWhiteSpace = (str: string): boolean => {
20+
return /\s+/.test(str)
21+
}
22+
23+
/**
24+
* Parses a number from a string without any surprises.
25+
* Strings that describe numbers (without any other characters involved) yield results.
26+
* Numbers that can be represented in binary, octal, decimal, hexadecimal, and scientific format are supported.
27+
* @param data
28+
*/
29+
export const parseNumberFromString: Parser<number> = (data) => {
30+
if (!isString(data)) {
31+
return failure('Expected a stringified number but did not get a string')
32+
}
33+
if (data === '') {
34+
return failure('Expected a stringified number but got an empty string')
35+
}
36+
if (hasWhiteSpace(data)) {
37+
return failure(
38+
'Expected a stringified number but got a string with whitespace',
39+
)
40+
}
41+
const parsed = Number(data)
42+
43+
if (isNaN(parsed) || !isFinite(parsed)) {
44+
return failure(
45+
`Expected a stringified number but got ${JSON.stringify(data)}`,
46+
)
47+
}
48+
return success(parsed)
49+
}

0 commit comments

Comments
 (0)