A simple math parser

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.