Here is a simple math expression parser for TypeScript.
It can take an input string like "2+2\*4" and properly return 10 as the result.
This can be used as a quick and lightweight way to do math on user-input expressions.
export type ParseResult = number | '' | Error
type Token =
| { type: 'number'; value: number }
| { type: 'op'; value: '+' | '-' | '*' | '/' }
| { type: 'lparen' }
| { type: 'rparen' }
function isDigit(char: string): boolean {
return char >= '0' && char <= '9'
}
function normalizeDecimals(input: string): string {
// Replace commas with dots and remove spaces
return input.replace(/,/g, '.').replace(/[\s]/g, '')
}
function tokenize(input: string): Token[] | Error {
const src = normalizeDecimals(input)
const tokens: Token[] = []
let i = 0
while (i < src.length) {
const ch = src[i]
if (isDigit(ch) || ch === '.') {
const start = i
let seenDot = ch === '.'
i++
while (i < src.length) {
const c = src[i]
if (isDigit(c)) {
i++
} else if (c === '.') {
if (seenDot) break
seenDot = true
i++
} else {
break
}
}
const numStr = src.slice(start, i)
if (numStr === '.' || numStr === '')
return new Error('Invalid number')
const value = Number(numStr)
if (Number.isNaN(value)) return new Error('Invalid number')
tokens.push({ type: 'number', value })
continue
}
if (ch === '+' || ch === '-' || ch === '*' || ch === '/') {
tokens.push({ type: 'op', value: ch })
i++
continue
}
if (ch === '(') {
tokens.push({ type: 'lparen' })
i++
continue
}
if (ch === ')') {
tokens.push({ type: 'rparen' })
i++
continue
}
return new Error('Invalid character')
}
return tokens
}
// Parser using recursive descent with support for unary +/-
// Grammar (after tokenization and whitespace removal):
// Expression := Term ((+|-) Term)*
// Term := Factor ((*|/) Factor)*
// Factor := (+|-) Factor | Primary
// Primary := Number | ( Expression )
function parseAndEvaluate(tokens: Token[]): number | Error {
let pos = 0
function peek(): Token | undefined {
return tokens[pos]
}
function consume<T extends Token['type']>(
type: T,
): Extract<Token, { type: T }> | undefined {
if (tokens[pos]?.type === type) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return tokens[pos++] as any
}
return undefined
}
function parseExpression(): number | Error {
let left = parseTerm()
if (left instanceof Error) return left
// eslint-disable-next-line no-constant-condition
while (true) {
const t = peek()
if (t && t.type === 'op' && (t.value === '+' || t.value === '-')) {
pos++
const right = parseTerm()
if (right instanceof Error) return right
left = t.value === '+' ? left + right : left - right
} else {
break
}
}
return left
}
function parseTerm(): number | Error {
let left = parseFactor()
if (left instanceof Error) return left
// eslint-disable-next-line no-constant-condition
while (true) {
const t = peek()
if (t && t.type === 'op' && (t.value === '*' || t.value === '/')) {
pos++
const right = parseFactor()
if (right instanceof Error) return right
if (t.value === '/') {
if (right === 0) {
// Per requirement: return 0 on division by zero
left = 0
} else {
left = left / right
}
} else {
left = left * right
}
} else {
break
}
}
return left
}
function parseFactor(): number | Error {
const t = peek()
if (!t) return new Error('Unexpected end')
// Unary operators
if (t.type === 'op' && (t.value === '+' || t.value === '-')) {
pos++
const val = parseFactor()
if (val instanceof Error) return val
return t.value === '+' ? +val : -val
}
return parsePrimary()
}
function parsePrimary(): number | Error {
const t = peek()
if (!t) return new Error('Unexpected end')
if (t.type === 'number') {
pos++
return t.value
}
if (t.type === 'lparen') {
pos++
const value = parseExpression()
if (value instanceof Error) return value
const r = consume('rparen')
if (!r) return new Error('Unbalanced parentheses')
return value
}
return new Error('Unexpected token')
}
const result = parseExpression()
if (result instanceof Error) return result
if (pos !== tokens.length) return new Error('Trailing input')
return result
}
export function parseMath(input: string): ParseResult {
const trimmed = input.trim()
if (trimmed === '') return ''
const tokens = tokenize(trimmed)
if (tokens instanceof Error) return tokens
// Disallow expressions that end with an operator
if (tokens.length > 0) {
const last = tokens[tokens.length - 1]
if (last.type === 'op') return new Error('Ends with operator')
// Leading illegal operator: '*' or '/'
const first = tokens[0]
if (
first.type === 'op' &&
(first.value === '*' || first.value === '/')
) {
return new Error('Illegal leading operator')
}
}
const value = parseAndEvaluate(tokens)
return value
}
I've used this as a common enhancement in projects I've been working on, for number input fields. The luxury of not leaving the application to do a calculation (especially on mobile) is both a great and a subtle improvement.