How-to › Parse, validate and transform data

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.

Audience
Library maintainer
Level
advanced
Topic
Parse config and data formats
Languages
TypeScript and Go
Verified

A CI script needs the services, methods, and field numbers from your .proto files, and the runner has no protoc. A parser from npm reads them, including [(acme.sensitve) = true] with a letter missing. It keeps the option under the name as written, so the check that every sensitive field is redacted finds no sensitive field and passes.

What you get

You will end up with a script that lists the services, methods, and field numbers in a .proto tree and refuses a custom option no extension declares. This is for you if a lint reads your protobuf files with no compiler installed.

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
buf with @bufbuild/protobufCI can run one binary, and you want every import, type, and option resolvedA compiler in the build, and options that stay unread bytes until you pass the registryNo step may run a binary, or the parse has to run inside an editor
jhump/protoreflectA Go tool needs resolved descriptors in its own process, with no binaryA deprecated package, and custom options that read as unset until you re-read themNew Go code, which protocompile serves directly
protobufjsNode code already on protobufjs that wants its imports followed for itRegistering descriptor.proto yourself, and option names it never checks against an extensionAn option drives a lint, a redaction rule, or a security check
@tabnas/protoA light parse into JSON shaped like descriptor.proto, from TypeScript or GoImport resolution and option checks you write yourself, and line numbers only on errorsYou 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. Walk the object for services, methods, and field numbers.

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(', ')}`)
node list.mjs
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 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.

// 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, 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.

// 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'))
node compare.mjs
@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 so you can look up any message, service, or extension by its full name.

// 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 }))}`)
npx buf build protos -o descriptors.binpb && node descriptors.mjs
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 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, which it wraps. Reading a custom option takes one extra step.

// 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
}
go run .
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. 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.

node --test read.test.mjs
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.

node pitfall.mjs
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.

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.

Footnotes

  1. Custom option numbers are handed out by a Markdown file. The Protobuf Global Extension Registry 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 sets 50000 to 99999 aside for use inside an organization, which is how acme.sensitive came to hold 50001 without asking anyone. ↩︎ Back to text

  2. The protobufjs source 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. ↩︎ Back to text

  3. The Buf documentation on 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. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.