Skip to content

Latest commit

 

History

History
354 lines (303 loc) · 9.36 KB

File metadata and controls

354 lines (303 loc) · 9.36 KB
id schema-json-ast
title Parsing JSON into Typed Abstract Syntax Trees
category recursive
skillLevel advanced
tags
schema
recursive
ast
json
parsing
compilers
lessonOrder 4
rule
description
Parse JSON into Typed Abstract Syntax Trees using Schema.
summary You need to parse arbitrary JSON into a typed, manipulable structure. A JSON file could contain numbers, strings, arrays, objects—all nested. Without an AST schema, you work with `any` and lose type...

Problem

You need to parse arbitrary JSON into a typed, manipulable structure. A JSON file could contain numbers, strings, arrays, objects—all nested. Without an AST schema, you work with any and lose type safety. You need a schema that validates JSON structure and gives you a fully-typed tree for traversal and transformation.

Solution

import { Schema, Effect } from "effect"

// ============================================
// 1. JSON AST types
// ============================================

type JsonValue =
  | { type: "null" }
  | { type: "boolean"; value: boolean }
  | { type: "number"; value: number }
  | { type: "string"; value: string }
  | { type: "array"; elements: JsonValue[] }
  | { type: "object"; properties: Record<string, JsonValue> }

const JsonValue: Schema.Schema<JsonValue> = Schema.suspend(() =>
  Schema.Union(
    Schema.Struct({ type: Schema.Literal("null") }),
    Schema.Struct({
      type: Schema.Literal("boolean"),
      value: Schema.Boolean,
    }),
    Schema.Struct({
      type: Schema.Literal("number"),
      value: Schema.Number,
    }),
    Schema.Struct({
      type: Schema.Literal("string"),
      value: Schema.String,
    }),
    Schema.Struct({
      type: Schema.Literal("array"),
      elements: Schema.Array(JsonValue),
    }),
    Schema.Struct({
      type: Schema.Literal("object"),
      properties: Schema.Record(Schema.String, JsonValue),
    })
  )
)

// ============================================
// 2. Convert raw JSON to AST
// ============================================

const jsonToAst = (json: unknown): JsonValue => {
  if (json === null) {
    return { type: "null" }
  }

  if (typeof json === "boolean") {
    return { type: "boolean", value: json }
  }

  if (typeof json === "number") {
    return { type: "number", value: json }
  }

  if (typeof json === "string") {
    return { type: "string", value: json }
  }

  if (Array.isArray(json)) {
    return {
      type: "array",
      elements: json.map(jsonToAst),
    }
  }

  if (typeof json === "object") {
    const properties: Record<string, JsonValue> = {}
    for (const [key, value] of Object.entries(json)) {
      properties[key] = jsonToAst(value)
    }
    return { type: "object", properties }
  }

  throw new Error(`Unknown JSON type: ${typeof json}`)
}

// ============================================
// 3. Convert AST back to JSON
// ============================================

const astToJson = (ast: JsonValue): unknown => {
  switch (ast.type) {
    case "null":
      return null
    case "boolean":
      return ast.value
    case "number":
      return ast.value
    case "string":
      return ast.value
    case "array":
      return ast.elements.map(astToJson)
    case "object":
      const obj: Record<string, unknown> = {}
      for (const [key, value] of Object.entries(ast.properties)) {
        obj[key] = astToJson(value)
      }
      return obj
  }
}

// ============================================
// 4. AST utilities
// ============================================

const findPaths = (
  ast: JsonValue,
  predicate: (v: JsonValue) => boolean,
  path: string = "root"
): string[] => {
  const results: string[] = []

  if (predicate(ast)) {
    results.push(path)
  }

  if (ast.type === "array") {
    for (let i = 0; i < ast.elements.length; i++) {
      results.push(
        ...findPaths(ast.elements[i], predicate, `${path}[${i}]`)
      )
    }
  } else if (ast.type === "object") {
    for (const [key, value] of Object.entries(ast.properties)) {
      results.push(...findPaths(value, predicate, `${path}.${key}`))
    }
  }

  return results
}

const transformAst = (
  ast: JsonValue,
  fn: (v: JsonValue) => JsonValue
): JsonValue => {
  const transformed = fn(ast)

  switch (transformed.type) {
    case "array":
      return {
        ...transformed,
        elements: transformed.elements.map((e) => transformAst(e, fn)),
      }
    case "object":
      const newProps: Record<string, JsonValue> = {}
      for (const [key, value] of Object.entries(
        transformed.properties
      )) {
        newProps[key] = transformAst(value, fn)
      }
      return { ...transformed, properties: newProps }
    default:
      return transformed
  }
}

const prettyPrint = (
  ast: JsonValue,
  indent: number = 0
): Effect.Effect<void> =>
  Effect.gen(function* () {
    const prefix = "  ".repeat(indent)

    switch (ast.type) {
      case "null":
        yield* Effect.log(`${prefix}null`)
        break
      case "boolean":
        yield* Effect.log(`${prefix}${ast.value}`)
        break
      case "number":
        yield* Effect.log(`${prefix}${ast.value}`)
        break
      case "string":
        yield* Effect.log(`${prefix}"${ast.value}"`)
        break
      case "array":
        yield* Effect.log(`${prefix}[`)
        for (let i = 0; i < ast.elements.length; i++) {
          yield* prettyPrint(ast.elements[i], indent + 1)
          if (i < ast.elements.length - 1) {
            yield* Effect.log(",")
          }
        }
        yield* Effect.log(`${prefix}]`)
        break
      case "object":
        yield* Effect.log(`${prefix}{`)
        const entries = Object.entries(ast.properties)
        for (let i = 0; i < entries.length; i++) {
          const [key, value] = entries[i]
          yield* Effect.log(`${prefix}  "${key}":`)
          yield* prettyPrint(value, indent + 2)
          if (i < entries.length - 1) {
            yield* Effect.log(",")
          }
        }
        yield* Effect.log(`${prefix}}`)
        break
    }
  })

// ============================================
// 5. Application logic
// ============================================

const appLogic = Effect.gen(function* () {
  console.log("=== JSON to AST Conversion ===\n")

  const jsonData = {
    name: "Effect-TS",
    version: "3.0.0",
    active: true,
    tags: ["typescript", "functional", "effect"],
    maintainers: [
      { name: "John Doe", email: "john@example.com" },
      { name: "Jane Smith", email: "jane@example.com" },
    ],
    stats: {
      downloads: 100000,
      stars: 5000,
      issues: 42,
    },
    deprecated: null,
  }

  console.log("Raw JSON:")
  console.log(JSON.stringify(jsonData, null, 2))

  console.log("\n--- Converting to AST ---\n")

  const ast = jsonToAst(jsonData)

  console.log("AST representation (excerpt):")
  console.log(`Root type: ${ast.type}`)
  if (ast.type === "object") {
    console.log(`Properties: ${Object.keys(ast.properties).join(", ")}`)
  }

  console.log("\n--- Pretty Printed AST ---\n")
  yield* prettyPrint(ast)

  console.log("\n--- Finding string values ---\n")

  const stringPaths = findPaths(
    ast,
    (v) => v.type === "string",
    "root"
  )

  console.log("Paths with strings:")
  for (const path of stringPaths) {
    console.log(`  ${path}`)
  }

  console.log("\n--- Transforming AST ---\n")

  // Double all numbers
  const transformed = transformAst(ast, (v) => {
    if (v.type === "number") {
      return { type: "number", value: v.value * 2 }
    }
    return v
  })

  console.log("After doubling all numbers:")
  const result = astToJson(transformed)
  if (typeof result === "object" && result !== null) {
    const stats = (result as any).stats
    console.log(`  downloads: ${stats.downloads}`)
    console.log(`  stars: ${stats.stars}`)
  }

  console.log("\n--- Validation ---\n")

  // Validate AST structure
  const validationResult = yield* Effect.tryPromise({
    try: () => Schema.decodeUnknown(JsonValue)(ast),
    catch: (error) => {
      const msg = error instanceof Error ? error.message : String(error)
      return new Error(`AST validation failed: ${msg}`)
    },
  })

  console.log("✅ AST is valid")
  console.log(`Roundtrip successful: ${JSON.stringify(astToJson(ast)) === JSON.stringify(jsonData)}`)

  return { ast, transformed }
})

// Run application
Effect.runPromise(appLogic)
  .then(() => console.log("\n✅ AST parsing complete"))
  .catch((error) => console.error(`Error: ${error.message}`))

Why This Works

Concept Explanation
Recursive AST JsonValue recursively contains other JsonValues
Exhaustive types All JSON types represented (null, bool, number, string, array, object)
Type safety No any types; all transformations are type-checked
Traversal Walk tree with standard recursive algorithms
Transformation Transform entire trees with composable functions
Roundtrip Convert JSON → AST → JSON without data loss
Validation Schema validates entire JSON structure

When to Use

  • JSON parsing with full type safety
  • Configuration file transformations
  • Template engines and macro systems
  • Code generation from JSON schemas
  • AST-based linting or analysis
  • Configuration validation and merging
  • API response transformation
  • Build tool configurations

Related Patterns