Lesson 15 / 18
Package Layout and Visibility
Whether an export is visible from one package to another is tied to the case of a single letter, shown by running the compiler from within a program across four trials: an uppercase function and a struct constructed only with its exported field compile; a lowercase function and an attempt to read a struct's unexported field from another package do not. The limiting measurement shows that two separate files of the same package share each other's lowercase name without any trouble at all — the boundary of visibility is not the file, it is the package.
Published
In this lesson
- Tests, by calling the compiler from within the program, whether an exported and an unexported name compile when called from another package
- Shows that a struct's unexported field can be constructed from another package but not read
- Measures that two separate files of the same package share a lowercase name without any trouble, showing that the boundary of visibility is the package, not the file
Contents
The previous lesson’s closing established that what a string is is stated by the language’s
type system, but what it means is not. This lesson carries the same question outside the
source: who determines a name’s visibility, the selection of a version, and the application of
a convention? The first stop is visibility. Go has no keyword to write for making an identifier
accessible to another package — there is no public, private, or similar declaration. The
only thing written is the name, and whether the first letter of that name is uppercase or
lowercase decides everything. This lesson’s question is the same as the course’s general
question: when you do not write this — that is, when you do not write the letter as uppercase —
what happens?
This question’s place in the course’s general framework is a little different. In the previous topic, the unwritten thing was always a value — a variable given no initial value, a return left unused. Here the unwritten thing is an access decision: not writing a name with an uppercase letter. The answer, too, becomes dependent on position — the same lowercase name works in a defined way inside its own package, but is rejected when called from outside the package. While the rest of the course had a single class corresponding to a single outcome, in this lesson the counterpart of the same unwrittenness changes depending on where it is viewed from — the measurement, too, is built accordingly by calling the same name from two different positions.
The Rule of Exporting: One Letter
PT1. Go carries no separate declaration syntax for visibility; whether an identifier is visible from outside the package is determined solely by whether the first letter of the name is uppercase (exported) or lowercase (unexported). PT2. This rule is not specific to a single kind of identifier — a function, a type, a variable, a constant, and a struct’s field all pass through the same rule, looking at the same letter.
This resembles the design decision built in the previous lesson around the zero value: the language itself determines the counterpart of something unwritten. The difference is in direction: the zero value filled an unwritten value; the export rule fills an unwritten access declaration — and when nothing at all is written besides the case of the letter, the compiler always reads this as “not exported.” The decision itself is not a choice, it is a mandatory consequence of how the name is written.
The rationale for this design is not a mere shortcut. A separate visibility declaration (like
public/private) asks for a second decision for every identifier: choose the name, then choose
the visibility. Go removes this second decision by embedding it in the name itself — changing an
identifier’s visibility means changing its name, and the two can never be separated from each
other. One consequence of this decision is that visibility is not a run-time check at all, it is
a syntax rule resolved entirely at compile time. The measurement below tests exactly this,
without performing a second run at all, only by calling the compiler — a visibility violation
appears not as an exception or an error value, but as the compilation itself stopping.
The Area a Letter Covers
The same rule is applied silently to other kinds of identifiers built over the past three
lessons as well. In the previous topic, constants ordered with iota are exported if written in
uppercase and stay confined to their own package if written in lowercase — there is no separate
rule for the constant’s value, the same rule applies only to its name. The name of a method
bound to a struct passes the same test too: even if its receiver (a concept built in the
Composite Types topic) is an exported type, if the method’s own name starts with a lowercase
letter, that method can only be called from its own package. What a package offers to the
outside can therefore be read by looking at individual names: someone scanning a package’s
source does not search for a separate declaration list to see which function, type, constant, or
method is accessible from outside — they only look at the first letters. This readability is not
a side effect, it is the rule’s actual purpose: a package’s public surface becomes information
that can be extracted from the source itself alone, with no other document to consult.
Calling the Compiler with a Two-Package Setup
The procedure running since the course’s first lesson expands here: the compiler is tested by actually compiling the source, the error text is never read, only the compiled/did-not-compile result is taken. That lesson’s setup tested a single package; this lesson builds a two-package setup, because the place where visibility is actually tested is the package boundary itself. The routines below write a library package to a separate subdirectory, the calling side to the module’s root, and compile the two together.
// tools/tools.go — shared routines that call the compiler from within the program
package tools
import (
"os"
"os/exec"
"path/filepath"
)
// TwoPackagesCompile writes librarySource to the "trial/library" subpackage and
// mainSource to the temporary module root, then compiles them: visibility between the
// two packages is tested by a real compiler call. The error text is not read, only success.
func TwoPackagesCompile(librarySource, mainSource string) bool {
dir, err := os.MkdirTemp("", "trial")
if err != nil {
return false
}
defer os.RemoveAll(dir)
os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module trial\n\ngo 1.24\n"), 0o644)
os.MkdirAll(filepath.Join(dir, "library"), 0o755)
os.WriteFile(filepath.Join(dir, "library", "library.go"), []byte(librarySource), 0o644)
os.WriteFile(filepath.Join(dir, "main.go"), []byte(mainSource), 0o644)
return run(dir)
}
// SamePackageTwoFilesCompile writes two files of the same package (one defining, one
// reading) to a single temporary module's root directory and compiles them.
func SamePackageTwoFilesCompile(fileA, fileB string) bool {
dir, err := os.MkdirTemp("", "trial")
if err != nil {
return false
}
defer os.RemoveAll(dir)
os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module trial\n\ngo 1.24\n"), 0o644)
os.WriteFile(filepath.Join(dir, "a.go"), []byte(fileA), 0o644)
os.WriteFile(filepath.Join(dir, "b.go"), []byte(fileB), 0o644)
return run(dir)
}
func run(dir string) bool {
cmd := exec.Command("go", "build", "./...")
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOTOOLCHAIN=local")
return cmd.Run() == nil
}
TwoPackagesCompile writes a library source to a subpackage named library, and the source
calling it to the module’s root — the only link between the two is the import line. The
compiler (appearing in the code block only as the argument of the exec.Command("go", "build", ...) call) runs against a completely new, empty directory every single time — the remnant of a
previous trial never affects the next one. Every trial sets up its own temporary module, because
otherwise a file left by one trial could muddy the next trial’s result; a fresh directory
guarantees that what is measured is only the two sources written at that moment.
SamePackageTwoFilesCompile asks a different question: what happens if two files belong to the
same package? Outside these two routines the tools package knows nothing at all — which
name is “library,” which is “main,” which letter is uppercase, all of that stays outside this
package, in the source texts the calling side constructs. The GOTOOLCHAIN=local environment
variable is given in both routines as well, so the inner compiler invocation does not try to
download its own toolchain from the network, only uses what is already installed.
Five Trials
The measurement below writes the same function and the same struct in several forms and tests them between two packages: one function name, two spellings of a struct literal, and one field read.
// main.go — the rule of exporting and the boundary of a package are not the boundary of a file
package main
import (
"fmt"
"lesson/tools"
)
func show(question string, compiled bool) {
fmt.Printf("%-53s %v\n", question, compiled)
}
func main() {
libraryUpper := "package library\n\nfunc Add(a, b int) int { return a + b }\n"
mainUpper := "package main\n\nimport \"trial/library\"\n\nfunc main() { _ = library.Add(2, 3) }\n"
show("Add (uppercase), called from another package", tools.TwoPackagesCompile(libraryUpper, mainUpper))
libraryLower := "package library\n\nfunc add(a, b int) int { return a + b }\n"
mainLower := "package main\n\nimport \"trial/library\"\n\nfunc main() { _ = library.add(2, 3) }\n"
show("add (lowercase), called from another package", tools.TwoPackagesCompile(libraryLower, mainLower))
libraryStruct := "package library\n\ntype Record struct {\n\tName string\n\tsecret int\n}\n"
mainStructNamed := "package main\n\nimport \"trial/library\"\n\nfunc main() { _ = library.Record{Name: \"x\"} }\n"
show("Record{Name: \"x\"}, constructed with the named field", tools.TwoPackagesCompile(libraryStruct, mainStructNamed))
mainStructPositional := "package main\n\nimport \"trial/library\"\n\nfunc main() { _ = library.Record{\"x\", 0} }\n"
show("Record{\"x\", 0}, constructed positionally", tools.TwoPackagesCompile(libraryStruct, mainStructPositional))
mainStructRead := "package main\n\nimport \"trial/library\"\n\nfunc main() { r := library.Record{Name: \"x\"}; _ = r.secret }\n"
show("r.secret, read from another package", tools.TwoPackagesCompile(libraryStruct, mainStructRead))
fmt.Println()
fileA := "package library\n\nvar value = 42\n"
fileB := "package library\n\nfunc Read() int { return value }\n"
show("value (lowercase), read from another file of the same package", tools.SamePackageTwoFilesCompile(fileA, fileB))
}
Add (uppercase), called from another package true
add (lowercase), called from another package false
Record{Name: "x"}, constructed with the named field true
Record{"x", 0}, constructed positionally false
r.secret, read from another package false
value (lowercase), read from another file of the same package true
PT3. Two of five cross-package trials compile, three do not; what determines the split is,
every single time, the first letter of the name tried, or whether the spelling implicitly
touches that letter. PT4. Record{Name: "x"} — the literal constructed with the named field
— compiles: the Record struct having an unexported secret field does not prevent using the
struct itself or only its exported fields. PT5. The same struct fails to compile when
constructed positionally with Record{"x", 0}, that is, without writing the field name; this
spelling implicitly assigns a value to the second position, the secret field, and the compiler
rejects this as an assignment made from outside the package to an unexported field. PT6. The
r.secret read also fails to compile — the same instance of the same struct is rejected when a
different field is requested.
Reading the third and fourth lines together (the named literal, the positional literal) shows
that the export rule operates per field and is sensitive to the spelling used: even if a
struct is exported as a whole (Record is already exported here since it starts with an
uppercase letter), each of its fields is evaluated separately according to its own letter. The
named literal says it is writing only the Name field, and never touches secret at all — the
compiler accepts this and leaves secret its own zero value. The positional literal, on the
other hand, requires assigning a value to every field in order; the second position falls on
the secret field, and this, even though it is not written in the source text, is equivalent in
the compiler’s eyes to writing to an unexported field from outside the package. Reading a field
of the record (r.secret) belongs to the same family: mentioning the name from outside the
package, whether reading or implicitly writing, is rejected either way. The compiler prints no
error text in any of the three cases — only compiled/did-not-compile is read — but the common
cause of all three is the same: mentioning, in a source text, a name not visible from outside the
package.
The Boundary Is the Package Boundary, Not the File Boundary
PT7. Two separate source files of the same package, with no import at all between them,
directly share each other’s unexported name: a.go defines the lowercase value variable,
b.go reads it directly, and both compile.
This is the lesson’s limiting measurement. We said the visibility rule looks at the case of the
letter, but the answer to what this rule is applied against is not “the file.” In the
measurement above, a.go and b.go look like two separate files, written in two separate
compilation units — but both carry the package library declaration, and this single line puts
both of them, in the compiler’s eyes, into one single unified namespace. Because value
starts with a lowercase letter, it is never visible from outside the package at all, but it is
directly visible from any file of the same package, with no import written at all.
This distinction is often overlooked because in many languages the compilation unit is the file itself, and the names a file sees are limited to that file’s own declarations. In Go the compilation unit is the package; how many files a package is split into is a decision made purely for organizational convenience, and it never changes visibility at all. Splitting a package into ten files versus keeping it in a single file gives exactly the same result in terms of which names that package exports and does not export — the only difference is which file the person reading the source looks at, not the set of names the compiler sees.
This has a practical consequence: splitting a package into multiple files is purely for
readability, and causes no loss of access at all. Real packages are usually split by topic for
this reason — one file carries the types, another the functions over those types, a third the
error values — and these files share each other’s unexported names with no extra declaration
written at all. The measurement confirms this from the reverse direction too: there is no bridge
code at all carrying value between a.go and b.go, because no bridge is needed — the moment
the two share the package library line, they enter the same namespace. If the boundary of
visibility were the file, the cost of splitting a package into multiple files would be setting up
a new “internal API” every single time; in Go this cost does not exist at all, because the
boundary is already the package’s boundary, not the file’s.
This is not the same thing as scope (a concept built in the Programming Fundamentals course).
Scope determines where a name can be seen — at a block, function, or package level; the export
rule, by contrast, decides whether a name already visible at the package level can be seen from
outside that package. value belongs to the same package scope in every file of the package;
the export rule does not change this scope itself, it only determines whether that scope can
cross the package boundary.
Summary
- Go has no separate declaration syntax for visibility; whether a name is exported is determined solely by whether its first letter is uppercase or lowercase.
- This rule works the same way for a function, a type, a variable, a constant, and a struct field; even if a struct is exported as a whole, each of its fields is evaluated separately according to its own letter.
- Two of five cross-package trials (an uppercase function, a struct literal constructing only the exported field) compile; three (a lowercase function, a positional literal’s implicit hidden-field assignment, reading an unexported field) do not.
- The limiting measurement: the compilation unit is the package, not the file; two separate
files of the same package share each other’s lowercase name with no
importat all. - The compiler was again called from within the program in this lesson too, and only the compiled/did-not-compile result was read; the error text was never printed in any measurement.
Next Step
In this lesson, where the boundary stands — in the package, not the file — was measured by running it, and confirmed across six separate trials that visibility is resolved entirely at compile time, just by looking at a single letter. But a package is not sufficient on its own; it is often dependent on other packages, and the packages a module sees do not always sit inside that same module — they come from a separate module and carry a version. Which line, in what form, that version is written at in the source is most often never stated at all; sometimes it is written directly, sometimes it is derived from a dependency’s own dependency. The next lesson takes up this question: when a dependency’s version is not written, what does the module file select in its place, by what rule is this selection made, and why is the selected version not always the newest version available?
To keep your progress and take notes, Log in
My notes
Log in to take notes.