Wanna see something cool? Check out Angular Spotify 🎧

Validating Python Indentation in TypeScript

A friend of mine recently went for a coding interview and was given a small but tricky challenge: validate if a block of Python code has the correct indentation.

The catch? You cannot run the code. You are not supposed to validate syntax either. You just need to tell if the indentation structure is valid.

We were chatting and I thought β€” actually, this is a nice little puzzle. So I decided to write a validator in TypeScript just for fun.

Scope

This is a structural check only. We do not validate Python syntax or runtime behaviour. Just indentation.

Assumptions

To keep things simple and deterministic:

  • Indentation uses 4 spaces only
  • No mixing tabs and spaces
  • Any line ending with : should be followed by a more indented line

Example

Valid Code

number = 5
if number > 0:
    print("Positive number")
    if number % 2 == 0:
        print("Even number")
    else:
        print("Odd number")

Invalid Code Examples

  1. Missing indentation after :
number = 5
if number > 0:
print("Positive number")  # not indented
  1. Inconsistent indentation
number = 5
if number > 0:
    print("Positive number")
  print("This line has wrong indentation")  # 2 spaces instead of 4
  1. Invalid dedent
number = 5
if number > 0:
    print("Positive number")
        print("Too much indentation")  # 8 spaces when only 4 is valid
  1. Missing indentation in nested block
number = 5
if number > 0:
    if number % 2 == 0:
    print("Missing indentation")  # should be indented with 8 spaces

Implementation

Ideas

When reading the requirement, my thought process was pretty straightforward:

  1. Split the code by newlines
  2. Skip empty lines or lines with only comments
  3. For each non-empty line:
    • Count the number of leading spaces
    • Compare with the current expected indent
    • If the line ends with a colon (:), expect an indent increase on the next line. This is different from the valid parenthesis problem where it’s more straightforward - if the string starts with an opening bracket ({, [, or (), you know exactly what the expected closing bracket (}, ], or )) should be
    • Use a stack to track indent levels - push new levels when we enter a block, pop when we leave
    • If the indent does not match any level in the stack, it is invalid

Step #1: Basic Structure

Let’s start with the basic structure:

const TAB_SIZE = 4

export function validatePythonIndentation(input: string): boolean {
  if (!input) {
    return false;
  }
  const indents: number[] = [0];
  const lines = input.split('\n');
  for (const rawLine of lines) {
    const line = rawLine.trimEnd();
    // DO THE REAL STUFF
  }
}

function countSpacesFromBeginning(line: string): number {
  return line.length - line.trimStart().length;
}

function isLineStartNewBlock(line: string): boolean {
  return line.endsWith(':');
}

Step #2: Handling Indentation Nesting

Now we add the logic to handle indentation nesting:

  • Check the current line’s indentation against the expected levels stored in a stack (indents)
  • If the current indentation is less than the top of the stack, it means we are leaving one or more blocks β€” so we pop those indentation levels until the current indent matches the new top (I know it is yet clicked, the see the example below)
  • If the current indentation does not match the new top after popping, it is invalid
  • If the line ends with :, push a new expected indentation (current + 4 spaces) to the stack

The tricky part is the pop loop that handles dedents:

const TAB_SIZE = 4

export function validatePythonIndentation(input: string): boolean {
  if (!input) {
    return false;
  }
+  // Start with [0] because the first line should have no indentation
+  // This represents the base level of indentation (0 spaces)  
+  const indents: number[] = [0];
  const lines = input.split('\n');
  for (const rawLine of lines) {
    const line = rawLine.trimEnd();
    if (line === '') {
      continue // skip empty line;
    }

    const currentIndent = countSpacesFromBeginning(line);
+    while (indents.length && currentIndent < indents[indents.length - 1]) {
+      indents.pop();
+    }
    const expectedIndent = indents[indents.length - 1];

    if (currentIndent !== expectedIndent) {
      return false;
    }

    if (isLineStartNewBlock(line)) {
      const nextExpectedIndent = currentIndent + TAB_SIZE;
      indents.push(nextExpectedIndent);
    }
  }
+  return true;
}

function countSpacesFromBeginning(line: string): number {
  return line.length - line.trimStart().length;
}

function isLineStartNewBlock(line: string): boolean {
  return line.endsWith(':');
}

What is going on here?

  • The stack stores allowed indentation levels, starting with [0]
  • When a line has less indent than the last recorded indent, it means we must close some blocks
  • The loop pops those bigger indents until the top matches or is less than or equal to the current indent

Why return true?

Our validator checks every line. If no line violates the expected indent, the input is valid.

So return true at the end means:

I saw no mistake, so I approve.

How It Works: A Concrete Example

Let’s walk through this Python snippet using our indentation validator:

number = 5
if number > 0:
    if number % 2 == 0:
        print("Even")
    else:
        print("Odd")
print("done")

We assume TAB_SIZE = 4.

Initial Setup

  • indents = [0] Starts with 0 to mean top-level code should have no indentation.

Line-by-line

  1. number = 5

    • Indent = 0
    • Top of stack = 0 β†’ valid
    • Does not end with : β†’ no new block
    • indents = [0] (unchanged)
  2. if number > 0:

    • Indent = 0
    • Top of stack = 0 β†’ valid
    • Ends with : β†’ push 0 + 4 = 4
    • indents = [0, 4]
  3. if number % 2 == 0:

    • Indent = 4
    • Top of stack = 4 β†’ valid
    • Ends with : β†’ push 4 + 4 = 8
    • indents = [0, 4, 8]
  4. print("Even")

    • Indent = 8
    • Top of stack = 8 β†’ valid
    • No : β†’ no push
    • indents = [0, 4, 8] (unchanged)
  5. else:

    • Indent = 4
    • Top of stack = 8 β†’ mismatch, but less than top β†’ Pop until top is 4
    • After pop: indents = [0, 4]
    • Top = 4 β†’ match
    • Ends with : β†’ push 4 + 4 = 8
    • indents = [0, 4, 8]
  6. print("Odd")

    • Indent = 8
    • Top = 8 β†’ match
    • No : β†’ no push
    • indents = [0, 4, 8]
  7. print("done")

    • Indent = 0
    • Top = 8 β†’ pop to 4 β†’ pop to 0
    • Top = 0 β†’ match
    • No : β†’ done
    • indents = [0]

No invalid indentation found

So at the end, we never returned false. That means the indentation is valid, so we return true.

Playground

Try it out in the TypeScript Playground.

Published 18 May 2025

    Read more

    Β β€”Β React Aria Components - Slider with filled background
    Β β€”Β Learn Angular Signals by implementing your own
    Β β€”Β Cursor: Customize Your Sidebar Like VS Code
    Β β€”Β bundle install: Could not find MIME type database in the following locations
    Β β€”Β Netlify Redirects vs Gatsby Redirects: My Traffic Drop 90%