# How to read a protobuf IDL file without running protoc

> List the services, methods, and field numbers in a tree of .proto files from a script, and refuse the misspelled custom option that a plain parser accepts.

Source: https://voxgig.com/howto/read-a-protobuf-idl-without-protoc

- Audience: library-maintainer
- Level: advanced
- Languages: typescript, go
- Verified: 2026-10-21
- Published: 2026-10-20

## Short answer

Parse each `.proto` file with a plain IDL parser such as `@tabnas/proto` or protobufjs to list services, methods, and field numbers without `protoc`. A plain parse leaves imports unread and keeps custom options under whatever name was written, so resolve type names against the imported files and refuse any option no extension declares. Or let `buf build` compile a descriptor set and read it with `@bufbuild/protobuf`, which resolves both for you.

---
## You will need

Node 22 or later, Go 1.24 or later for the Go route, and a directory of `.proto` files. Verified
2026-10-21 against Node 22.22.0, Go 1.24.7, `@tabnas/proto` 0.6.11, `@tabnas/parser` 0.12.14,
protobufjs 8.8.1, `@bufbuild/protobuf` 2.16.0, buf 1.74.0, and protoreflect 1.18.0. The sample
reads three files: an invoice service, a `Money` message in the parent package, and the file that
declares the custom option. It generates no code and never calls `protoc`.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [buf with @bufbuild/protobuf](https://buf.build/docs/reference/cli/buf/build/) | CI can run one binary, and you want every import, type, and option resolved | A compiler in the build, and options that stay unread bytes until you pass the registry | No step may run a binary, or the parse has to run inside an editor |
| [jhump/protoreflect](https://pkg.go.dev/github.com/jhump/protoreflect/desc/protoparse) | A Go tool needs resolved descriptors in its own process, with no binary | A deprecated package, and custom options that read as unset until you re-read them | New Go code, which protocompile serves directly |
| [protobufjs](https://protobufjs.github.io/protobuf.js/) | Node code already on protobufjs that wants its imports followed for it | Registering `descriptor.proto` yourself, and option names it never checks against an extension | An option drives a lint, a redaction rule, or a security check |
| [@tabnas/proto](https://github.com/tabnas/proto) | A light parse into JSON shaped like `descriptor.proto`, from TypeScript or Go | Import resolution and option checks you write yourself, and line numbers only on errors | You want names resolved for you and can run a compiler |

Voxgig maintains tabnas. It is one of four options here, not the recommendation.

A compiler answers every question a lint asks, and it costs you a binary in the build. buf refuses
a misspelled option at its line and column, but its descriptor set hides the option from any
reader that has not loaded the extension. protobufjs follows imports and accepts any option name.
protoreflect compiles inside a Go program and returns custom options as unread bytes. `@tabnas/proto`
stops at the parse, so resolution is code you own.

## List what one file declares

`@tabnas/proto` turns one file into JSON shaped like `FileDescriptorProto`, the message a compiler
builds for each file it reads. It ships for TypeScript and as a
[Go module that tracks it](https://tabnas.dev/docs/packages/). Walk the object for services,
methods, and field numbers.

```js title="list.mjs"
const file = 'billing/v1/invoice.proto'
const fdp = parse(readFileSync(`protos/${file}`, 'utf8'))

console.log(`${file}, package ${fdp.package}`)
for (const s of fdp.service) {
  console.log(`service ${s.name}`)
  for (const m of s.method) console.log(`  rpc ${m.name}(${m.inputType}) returns (${m.outputType})`)
}
for (const m of fdp.messageType) {
  console.log(`message ${m.name}`)
  for (const f of m.field) {
    // A scalar has a type; a message or enum reference has only the name as written.
    const type = f.typeName ?? f.type.replace('TYPE_', '').toLowerCase()
    const label = f.label === 'LABEL_REPEATED' ? 'repeated ' : ''
    const options = Object.entries(f.options ?? {}).map(([k, v]) => `${k} = ${v}`).join(', ')
    console.log(`  ${f.number} ${f.name} ${label}${type}${options ? ` [${options}]` : ''}`)
  }
}
console.log(`imports listed, not read: ${fdp.dependency.join(', ')}`)
```

```bash
node list.mjs
```

```text output
billing/v1/invoice.proto, package billing.v1
service InvoiceService
  rpc GetInvoice(GetInvoiceRequest) returns (Invoice)
  rpc ListInvoices(ListInvoicesRequest) returns (ListInvoicesResponse)
message GetInvoiceRequest
  1 id string
message ListInvoicesRequest
  1 customer_id string
  2 page_size int32
message ListInvoicesResponse
  1 invoices repeated Invoice
message Invoice
  1 id string
  2 total Money
  3 card_number string [(acme.sensitive) = true]
imports listed, not read: acme/annotations.proto, billing/money.proto
```

The field numbers are all there, but each type is only a string. `Money` and `Invoice` are the
names as written, and the two imports are listed without being opened. The parser cannot know that
`Money` means `billing.Money`, because the file that says so is one it never read. The option
survives as the key `(acme.sensitive)`, spelled however its author spelled it.

## Resolve names against the imports

Type names resolve the way C++ scopes do: the innermost scope first, then each enclosing package,
as the [language guide on name resolution](https://protobuf.dev/programming-guides/proto3/#name-resolution)
puts it. Custom options need the same treatment. Each one is an extension of
`google.protobuf.FieldOptions` or a sibling message, declared with a number of its own, usually in
another file.[^1] The resolver in `resolve.mjs` reads the imports from one root, applies the scoping rule,
and refuses any option that no visible extension declares or whose value does not fit it.

```js title="resolve.mjs"
// Protobuf scoping, as protoc applies it: look the name's first part up in the
// innermost scope, then each enclosing one, and finish the lookup in the first
// scope that holds it. So `billing.Money` written in package acme.billing means
// acme.billing.Money, and fails if that is missing, even though package billing
// declares Money. A leading dot starts the lookup at the root.
export function resolveName(written, scope, seen) {
  if (written.startsWith('.')) return seen.has(written.slice(1)) ? written.slice(1) : null
  const first = written.split('.')[0]
  const parts = scope.split('.').filter(Boolean)
  for (let i = parts.length; i >= 0; i--) {
    const outer = parts.slice(0, i).join('.')
    const hit = seen.get(qualify(outer, first))
    // A bare name passes over a package, which is never a type or an option,
    // and a dotted one over an extension, which holds no names.
    if (!hit || hit.kind === (first === written ? 'package' : 'extension')) continue
    return seen.has(qualify(outer, written)) ? qualify(outer, written) : null
  }
  return null
}

// What a value of each scalar option type parses to. An enum value arrives as
// its name and a message value as text, so neither is checked here.
const fits = (ext, value) =>
  ext.typeName ? true
  : ext.type === 'TYPE_BOOL' ? typeof value === 'boolean'
  : ext.type === 'TYPE_STRING' || ext.type === 'TYPE_BYTES' ? typeof value === 'string'
  : typeof value === 'number' || /^-?(inf|nan|0x[\da-f]+)$/i.test(value)

// A custom option is a name in parentheses, kept by the parser as written.
// Refuse one that no visible extension of the right options message declares,
// and a value of the wrong type: `= "true"` on a bool option is a string.
function customOptions(options, kind, scope, where, seen, problems) {
  const found = {}
  for (const [key, value] of Object.entries(options ?? {})) {
    const m = /^\((\.?[\w.]+)\)/.exec(key)
    if (!m) continue
    const full = resolveName(m[1], scope, seen)
    const ext = full && seen.get(full)
    if (!ext || ext.kind !== 'extension') {
      problems.push(`${where}: option (${m[1]}) names no extension`)
    } else if (ext.decl.extendee.replace(/^\./, '') !== OPTIONS_OF[kind]) {
      problems.push(`${where}: (${m[1]}) extends ${ext.decl.extendee}, not ${OPTIONS_OF[kind]}`)
    } else if (!fits(ext.decl, value)) {
      problems.push(`${where}: (${m[1]}) takes a ${ext.decl.type.slice(5).toLowerCase()}, not ${JSON.stringify(value)}`)
    } else {
      found[full] = value
    }
  }
  return found
}
```

protobufjs walks the imports itself through
[`Root.resolvePath`](https://protobufjs.github.io/protobuf.js/Root.html#resolvePath), once
`descriptor.proto` is registered. The package ships a copy of that file and leaves it
unregistered,[^2] so an import of it fails with `ENOENT` until you register it.

```js title="routes.mjs"
// protobufjs bundles descriptor.proto as JSON but does not register it, so an
// import of google/protobuf/descriptor.proto fails until you do.
protobuf.common('google/protobuf/descriptor.proto', require('protobufjs/google/protobuf/descriptor.json'))
```

```bash
node compare.mjs
```

```text output
@tabnas/proto 0.6.11 | protobufjs 8.8.1 | @bufbuild/protobuf 2.16.0 | buf 1.74.0

route                         Invoice.total   sensitive fields
@tabnas/proto parse           Money           Invoice.card_number
@tabnas/proto + resolve.mjs   billing.Money   Invoice.card_number
protobufjs Root.load          billing.Money   Invoice.card_number
buf + @bufbuild/protobuf      billing.Money   Invoice.card_number
```

The three resolving routes agree on `billing.Money` and on the one sensitive field. The plain parse
prints `Money`, which is right as text and not yet a type.

## Compile a descriptor set with buf

`buf build` compiles the tree with Buf's own compiler and writes an image, which its reference
calls wire compatible with a `FileDescriptorSet`. `createFileRegistry` in `@bufbuild/protobuf`
[indexes the set](https://protobufes.com/reference/reflection/registries/#file-registries) so you
can look up any message, service, or extension by its full name.

```js title="descriptors.mjs"
// A custom option is an extension, read with the extension's own descriptor.
const sensitive = registry.getExtension('acme.sensitive')
const file = registry.getFile('billing/v1/invoice.proto')
const invoice = registry.getMessage('billing.v1.Invoice')
for (const f of invoice.fields) {
  const type = f.message ? f.message.typeName : ScalarType[f.scalar].toLowerCase()
  const marked = f.proto.options && getExtension(f.proto.options, sensitive) ? ' (acme.sensitive)' : ''
  // Positions survive too. A field's path is [4, message index, 2, field index]:
  // message_type is field 4 of a file, field is field 2 of a message.
  const path = [4, file.proto.messageType.indexOf(invoice.proto), 2, invoice.proto.field.indexOf(f.proto)].join()
  const { span } = file.proto.sourceCodeInfo.location.find((l) => l.path.join() === path)
  console.log(`${f.number} ${f.name} ${type}${marked}, line ${span[0] + 1} column ${span[1] + 1}`)
}

// Until something names the extension, it is bytes the options message cannot
// read, and converting the options to JSON without the registry drops it.
const options = invoice.fields.find((f) => f.name === 'card_number').proto.options
console.log(`card_number options as JSON: ${JSON.stringify(toJson(FieldOptionsSchema, options))}`)
console.log(`the same, given the registry: ${JSON.stringify(toJson(FieldOptionsSchema, options, { registry }))}`)
```

```bash
npx buf build protos -o descriptors.binpb && node descriptors.mjs
```

```text output
4 files: google/protobuf/descriptor.proto, acme/annotations.proto, billing/money.proto, billing/v1/invoice.proto
rpc GetInvoice(billing.v1.GetInvoiceRequest) returns (billing.v1.Invoice)
rpc ListInvoices(billing.v1.ListInvoicesRequest) returns (billing.v1.ListInvoicesResponse)
1 id string, line 27 column 3
2 total billing.Money, line 28 column 3
3 card_number string (acme.sensitive), line 29 column 3
card_number options as JSON: {}
the same, given the registry: {"[acme.sensitive]":true}
```

Every name arrives resolved. The set also carries the imports and the line and column of each
declaration, which `protoc` leaves out unless asked.[^3] The last two lines are the trap on this
route. The [extensions guide](https://protobufes.com/guides/extensions/#extensions-and-json) says
extensions are stored as unknown fields, so `toJson` without the registry prints `{}`.

## Read the same tree from Go

protoreflect's `protoparse` compiles inside the Go program, so no binary is involved. Its
documentation marks it deprecated in favor of
[`bufbuild/protocompile`](https://pkg.go.dev/github.com/bufbuild/protocompile), which it wraps.
Reading a custom option takes one extra step.

```go title="main.go"
// optionValue reads a custom option from a field. The parser has checked the
// option against its extension, but this program has no generated code for
// that extension, so the options message carries it as unknown bytes and
// proto.GetExtension reports the default. Re-read the bytes with a resolver
// that knows the extension, then ask.
func optionValue(field, ext *desc.FieldDescriptor) (any, error) {
	xt := dynamicpb.NewExtensionType(ext.UnwrapField())
	types := new(protoregistry.Types)
	if err := types.RegisterExtension(xt); err != nil {
		return nil, err
	}
	raw, err := proto.Marshal(field.GetFieldOptions())
	if err != nil {
		return nil, err
	}
	opts := &descriptorpb.FieldOptions{}
	if err := (proto.UnmarshalOptions{Resolver: types}).Unmarshal(raw, opts); err != nil {
		return nil, err
	}
	return proto.GetExtension(opts, xt), nil
}
```

```bash
go run .
```

```text output
rpc GetInvoice(billing.v1.GetInvoiceRequest) returns (billing.v1.Invoice)
rpc ListInvoices(billing.v1.ListInvoicesRequest) returns (billing.v1.ListInvoicesResponse)
Invoice.total is billing.Money
(acme.sensitive) read straight from the options: false
(acme.sensitive) re-read with the extension: true
card_number is declared at line 29 column 3
misspelled option: billing/v1/invoice.proto:29:27: field billing.v1.Invoice.card_number: unknown extension acme.sensitve
```

The parser checked `(acme.sensitive)` against its declaration, yet the plain read returns `false`.
`proto.Unmarshal` looks extensions up in the global registry unless it is
[given a resolver](https://pkg.go.dev/google.golang.org/protobuf/proto#UnmarshalOptions). Generated
code registers its extensions there, and a descriptor parsed at run time does not, so the option
stays unknown bytes until `optionValue` re-reads them. The last line is the compiler refusing the
misspelling, one column earlier than buf placed it.

## Check it worked

`read.test.mjs` runs the three resolving routes over `protos/` and expects `billing.Money` and
`Invoice.card_number` from each. Then it breaks the tree the ways buf refuses: a misspelled option
wherever an option can sit, a `bool` option set to a string, and a message option set on a field. It
also tries a missing import, an import cycle, a path outside the root, and a name two files declare.

```bash
node --test read.test.mjs
```

```text output
1..16
# tests 16
# suites 0
# pass 16
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 2132.912266
```

Sixteen tests pass. One of them asserts the gap rather than hiding it: the plain parse and protobufjs
accept the misspelled option and report no sensitive field. The last pins the JSON shape the plain
parse shares with a compiled descriptor.

## When it goes wrong

A lint that keys on `(acme.sensitive)` reports no sensitive fields after an edit. The option name
was misspelled, and a plain parser keeps any name in parentheses as written. Check every option
name against the extensions the imports declare, or compile the tree.

```bash
node pitfall.mjs
```

```text output
Invoice.card_number written as [(acme.sensitve) = true]

@tabnas/proto parse           accepted, sensitive fields: none
@tabnas/proto + resolve.mjs   refused: billing/v1/invoice.proto: billing.v1.Invoice.card_number: option (acme.sensitve) names no extension
protobufjs Root.load          accepted, sensitive fields: none
buf + @bufbuild/protobuf      refused: billing/v1/invoice.proto:29:28:cannot find `acme.sensitve` in this scope
```

Two routes refuse the file and name the field or the line. The other two accept it, which leaves
every rule that depends on the option with nothing to apply to.

An option you can see in the file reads as unset. On a compiled route the extension is held as
unknown fields, so `proto.GetExtension` returns the default in Go and `toJson` leaves it out in
TypeScript. Re-read the options with a resolver that knows the extension, or pass the registry.

protobufjs throws `unresolvable extensions` from `resolveAll()`. A file declares
`extend google.protobuf.FieldOptions`, and the message it extends was never loaded. Register the
bundled JSON under its full path, `google/protobuf/descriptor.proto`, because the short name
`descriptor` wraps the JSON in `google.protobuf` a second time.

## When not to do this

Do not parse `.proto` text in every script when your build can produce a descriptor set. One
`buf build` in CI gives each consumer the same resolved tree, and each script then reads names
instead of writing its own scoping rules. The resolver here covers direct imports and protoc's
scoping rule. It skips `import public`, reads a built-in file such as `google/protobuf/timestamp.proto`
only from a copy under the root, and checks no field numbers, so a reused or reserved number passes.

Do not run an option-driven check on a plain parse. `@tabnas/proto` and protobufjs both keep an
option under any name, so one dropped letter turns the check into one that passes on nothing.

Reach for tabnas where a compiler cannot run, such as a browser or an editor extension, or where
a Go tool and a TypeScript tool should share one grammar. Its JSON follows `descriptor.proto`, so
a walker written against it can read a compiled set's JSON too. There, names arrive fully
qualified, fields gain `type` and `jsonName`, and options are keyed by extension in brackets. A
string value arrives with its escapes decoded there, where the plain parse keeps them as written.

Do not start new Go code on `protoparse`. Use protocompile, which it wraps.

## Related how-tos

- [Define a gRPC service in a proto3 file](/howto/define-a-grpc-service-in-proto3)

- [Decide when lenient parsing is the wrong choice](/howto/decide-when-lenient-parsing-is-wrong)

- [Parse one config format identically in TypeScript and Go](/howto/parse-one-config-format-in-typescript-and-go)

## Last verified

Verified 2026-10-21 against Node 22.22.0, Go 1.24.7, `@tabnas/proto` 0.6.11, `@tabnas/parser`
0.12.14, protobufjs 8.8.1, `@bufbuild/protobuf` 2.16.0, buf 1.74.0, protoreflect 1.18.0, and
`google.golang.org/protobuf` 1.36.11. Every output block is what the command preceding it printed.
protoreflect 1.18.1 and later declare Go 1.25 and its v2 module has no `protoparse`, so the sample
pins 1.18.0. `@tabnas/proto` declares Node 24 in its engines field; npm warned on Node 22 and every
command ran. `protoc` itself was not run: the defaults in the third note are as Buf's documentation
states them.

[^1]: Custom option numbers are handed out by a Markdown file. The
[Protobuf Global Extension Registry](https://github.com/protocolbuffers/protobuf/blob/main/docs/options.md)
lives in the protobuf repository, and a project claims a number by sending a pull request to it.
The list opens with the C# port of protocol buffers at 1000 and held 120 entries when this page
was verified. The [language guide](https://protobuf.dev/programming-guides/proto2/#customoptions)
sets 50000 to 99999 aside for use inside an organization, which is how `acme.sensitive` came to
hold 50001 without asking anyone.

[^2]: [The protobufjs source](https://github.com/protobufjs/protobuf.js/blob/master/src/common.js)
gives the reason in a comment that reads "Not provided because of limited use" and suggests you
discuss it or provide the file yourself. The file declares `FieldOptions` and the other messages
a custom option extends, so every `.proto` file that declares an option imports it.

[^3]: The [Buf documentation on images](https://buf.build/docs/reference/images/) records the
defaults: a set written by `protoc -o` holds only the files named on the command line, with no
source information, until `--include_imports` and `--include_source_info` ask for more. `buf build`
makes the opposite choice and includes both unless told to exclude them.