August 28, 2025 (Today)
I've been hearing about Go since I first started diving deeper into backend development, but I'll admit I took one look at it and thought, "Why would I need this when I already know JavaScript/TypeScript?" I saw what looked like a verbose, statically-typed language and thought, isn't this just making things more complicated?
The turning point came when I learned that the TypeScript team at Microsoft is exploring rewriting parts of the TypeScript compiler in Go for performance reasons. If the team behind TypeScript sees value in Go's approach to types and performance, maybe there's something worth exploring here! โจ
Instead of just focusing on Node.js and TypeScript, I decided to dive deep into Go. After working through the initial learning curve, I finally started to get it, and I began to see why I might want to use Go instead of JavaScript for certain types of applications - and why the TypeScript team might be making this transition.
I tried to condense everything I've learned into a nice introduction specifically for fellow JS/TS developers, so here it is.
There are a few fundamental programming concepts you should know in advance before you start playing around with Go. While this tutorial is aimed at JavaScript/TypeScript developers, the core requirements are universal programming basics.
Here are what I consider to be Go prerequisites:
Here's the source code and what we'll build by the end:
Go is a statically typed, compiled programming language developed at Google.
One of the most important aspects of Go is that it's designed for simplicity and performance. Coming from JavaScript, you might initially find Go verbose, but you'll quickly appreciate its explicitness and reliability.
Go excels at:
Before we start building, it's crucial to understand how Go's type system differs from TypeScript. This comparison will save you mental friction throughout the tutorial.
TypeScript: TypeScript adds types on top of JavaScript's dynamic nature. It's a gradual typing system - you can mix typed and untyped code, and types are erased at runtime.
// TypeScript - optional typing, gradual adoption
let name = "Alice"; // inferred as string
let age: number = 25; // explicit typing
let data: any = getSomeData(); // escape hatch
Go: Go is statically typed from the ground up. Every value has a type at compile time, and there's no runtime type erasure because types are part of the language design.
// Go - types are always present, even when inferred
var name string = "Alice" // explicit
age := 25 // inferred as int, but still strongly typed
var data interface{} = getSomeData() // closest to 'any', but not recommended
๐ก For TypeScript Developers: Go's
interface{}
might look like TypeScript'sany
, but it's fundamentally different. Whileany
disables type checking,interface{}
still maintains type safety - you just need type assertions to access the underlying value.
This is where Go's approach really shines and differs dramatically from TypeScript.
TypeScript - Structural Typing:
interface Writer {
write(data: string): void;
}
interface Logger {
write(message: string): void;
}
// These are the same type to TypeScript
const fileWriter: Writer = {
write: (data: string) => console.log(data)
};
const logger: Logger = fileWriter; // Works! Structural compatibility
Go - Implicit Interface Satisfaction:
// Define interface
type Writer interface {
Write(data []byte) (int, error)
}
// Any type with matching methods automatically satisfies the interface
type FileWriter struct{}
func (fw FileWriter) Write(data []byte) (int, error) {
// implementation
return len(data), nil
}
// No explicit "implements" - it just works!
var writer Writer = FileWriter{}
๐ก For TypeScript Developers: Go's interfaces are satisfied implicitly. You don't declare that a type implements an interface - if it has the right methods, it automatically does. This is more flexible than TypeScript's structural typing because it works across package boundaries without coordination.
TypeScript:
function parseJSON(data: string): object {
try {
return JSON.parse(data);
} catch (error) {
throw new Error(`Invalid JSON: ${error.message}`);
}
}
// Caller must remember to handle exceptions
try {
const result = parseJSON(invalidData);
} catch (error) {
console.log("Parsing failed:", error.message);
}
Go:
func parseJSON(data string) (map[string]interface{}, error) {
var result map[string]interface{}
err := json.Unmarshal([]byte(data), &result)
if err != nil {
return nil, fmt.Errorf("invalid JSON: %w", err)
}
return result, nil
}
// Errors are values, handling is explicit and visible
result, err := parseJSON(invalidData)
if err != nil {
fmt.Printf("Parsing failed: %v\n", err)
return
}
๐ก For TypeScript Developers: Go doesn't have exceptions. Errors are returned as values, making error handling explicit and visible in the code. This might feel verbose at first, but it prevents the "forgotten error handling" bugs common in JavaScript/TypeScript.
TypeScript/JavaScript:
// Garbage collected, dynamic allocation
const users: User[] = [];
for (let i = 0; i < 1000000; i++) {
users.push({ name: `User${i}`, age: i % 100 });
}
// GC pressure, potential pause times
Go:
// More predictable memory layout, efficient GC
users := make([]User, 0, 1000000) // pre-allocate capacity
for i := 0; i < 1000000; i++ {
users = append(users, User{
Name: fmt.Sprintf("User%d", i),
Age: i % 100,
})
}
// Better memory locality, faster execution
๐ก For TypeScript Developers: Go gives you more control over memory allocation. The
make()
function with capacity hints helps avoid repeated reallocations, leading to better performance than JavaScript's dynamic arrays.
Types are real: In TypeScript, types are compile-time helpers. In Go, types affect runtime behavior and performance.
Explicit is better: Where TypeScript tries to infer and be flexible, Go prefers explicit declarations and clear contracts.
Composition over inheritance: Go doesn't have classes. You compose behavior through interfaces and struct embedding.
Error handling is part of the type system: Functions that can fail return (result, error)
pairs, not exceptions.
Now that we understand these fundamental differences, let's see them in action as we build our library management system.
๐ก For TypeScript Developers: Go doesn't have exceptions. Errors are returned as values, making error handling explicit and visible in the code. This might feel verbose at first, but it prevents the "forgotten error handling" bugs common in JavaScript/TypeScript.
First, you need to install Go on your system. Visit golang.org/dl and download the installer for your operating system.
After installation, verify it works:
go version
Let's start with the simplest possible approach - a single Go file. This isn't how you'd structure a real Go project, but it's familiar if you're used to running Node.js scripts.
Create a new directory and file:
mkdir go-library
cd go-library
touch main.go
Add this to main.go
:
package main
import "fmt"
func main() {
fmt.Println("Welcome to Go Library Manager!")
}
Run it directly:
go run main.go
You'll see: Welcome to Go Library Manager!
This is similar to running node script.js
, but Go is compiling your code behind the scenes.
Now let's do it the proper way. Go uses modules (similar to npm packages) to manage dependencies and project structure.
Initialize a new Go module:
go mod init library-manager
This creates a go.mod
file (similar to package.json
). Now we can build and run our project properly:
go build
./library-manager # On Windows: library-manager.exe
Or run directly:
go run .
Before we build our library manager, let's explore the fundamental differences you'll encounter. Think of this as your translation guide from TypeScript concepts to Go equivalents.
In TypeScript, you might write:
let name = "Alice"; // inferred string
let age: number = 25; // explicit number
let isActive: boolean = true; // explicit boolean
const PI = 3.14159; // constant
In Go:
var name string = "Alice" // explicit type
var age int = 25 // explicit type
var isActive bool = true // explicit type
const PI = 3.14159 // constant (type inferred)
// Or the short form (Go infers types):
name := "Alice" // := means "declare and assign"
age := 25 // inferred as int
isActive := true // inferred as bool
๐ก For TypeScript Developers: The
:=
operator is Go's way of saying "I want to declare a new variable and assign to it in one step." It's like TypeScript'slet
with automatic type inference, but the variable is still strongly typed.
Key Difference: Go has zero values for all types. If you declare a variable without initializing it, Go gives it a sensible default:
var name string // "" (empty string)
var age int // 0
var isActive bool // false
var ptr *int // nil
This eliminates TypeScript's undefined
vs null
confusion - Go variables always have a value.
TypeScript approach:
function greet(name: string): string {
return `Hello, ${name}!`;
}
// Optional parameters and overloads
function greet(name: string, age?: number): string {
if (age) {
return `Hello, ${name}! You're ${age} years old.`;
}
return `Hello, ${name}!`;
}
Go's approach:
// Go functions are explicit about inputs and outputs
func greet(name string) string {
return "Hello, " + name + "!"
}
// Multiple return values (very common pattern)
func greetWithAge(name string, age int) (string, bool) {
if age < 0 {
return "", false // return empty string and "invalid" flag
}
return fmt.Sprintf("Hello, %s! You're %d years old.", name, age), true
}
// Named return values (optional but helpful for documentation)
// a and b has the same type
func divide(a, b int) (result int, err error) {
if b == 0 {
err = fmt.Errorf("cannot divide by zero")
return // returns zero value of int and the error
}
result = a / b
return // returns the named values
}
๐ก For TypeScript Developers: Go doesn't have function overloading or optional parameters. Instead, you either create separate functions with different names, or use the multiple return values pattern. This might feel limiting at first, but it makes function contracts very clear and predictable.
This is probably the biggest mental shift you'll make:
TypeScript's exception-based approach:
function riskyOperation(data: string): User {
if (!data) {
throw new Error("Data cannot be empty");
}
try {
const parsed = JSON.parse(data);
return new User(parsed.name, parsed.age);
} catch (error) {
throw new Error(`Failed to parse user data: ${error.message}`);
}
}
// Usage requires try-catch (often forgotten!)
try {
const user = riskyOperation(userData);
console.log(user.name);
} catch (error) {
console.error("Operation failed:", error.message);
}
Go's explicit error handling:
func riskyOperation(data string) (User, error) {
if data == "" {
return User{}, fmt.Errorf("data cannot be empty")
}
var parsed map[string]interface{}
err := json.Unmarshal([]byte(data), &parsed)
if err != nil {
return User{}, fmt.Errorf("failed to parse user data: %w", err)
}
name, ok := parsed["name"].(string)
if !ok {
return User{}, fmt.Errorf("name field missing or invalid")
}
age, ok := parsed["age"].(float64) // JSON numbers are float64
if !ok {
return User{}, fmt.Errorf("age field missing or invalid")
}
return User{Name: name, Age: int(age)}, nil
}
// Usage - error handling is explicit and visible
user, err := riskyOperation(userData)
if err != nil {
fmt.Printf("Operation failed: %v\n", err)
return
}
fmt.Println(user.Name)
๐ก For TypeScript Developers: Go's error handling might feel verbose, but it has huge advantages:
- Errors are visible in function signatures - you can't ignore them accidentally
- No hidden control flow - no exceptions jumping around your code
- Better debugging - error paths are explicit and traceable
- Performance - no expensive exception unwinding
One area where Go might surprise you:
TypeScript's any
type:
let data: any = getSomeData();
data.foo.bar.baz; // Compiles, but might crash at runtime
Go's interface{}
:
var data interface{} = getSomeData()
// data.foo // This won't compile!
// You must assert the type first
if user, ok := data.(User); ok {
fmt.Println(user.Name) // Now it's safe
}
// Or use type switch for multiple possibilities
switch v := data.(type) {
case string:
fmt.Println("Got string:", v)
case User:
fmt.Println("Got user:", v.Name)
case int:
fmt.Println("Got number:", v)
default:
fmt.Println("Unknown type")
}
๐ก For TypeScript Developers: Go's
interface{}
is not an escape hatch likeany
. It's a way to hold any type, but you must explicitly check and convert before using. This maintains type safety even with dynamic data.
Now let's see these concepts in action as we build our library management system step by step.
Let's start building our library management system. This first part will feel familiar if you're coming from Node.js, but notice the subtle yet important differences.
Replace your main.go
with:
package main
import (
"bufio"
"fmt"
"os"
"strings"
)
func main() {
fmt.Println("=== Welcome to Go Library Manager ===")
reader := bufio.NewReader(os.Stdin)
fmt.Print("Enter your name: ")
name, _ := reader.ReadString('\n')
name = strings.TrimSpace(name)
fmt.Printf("Hello, %s! Ready to manage some books?\n", name)
}
Let's break down what's happening here and why it's different from TypeScript/Node.js:
package main
import (
"bufio"
"fmt"
"os"
"strings"
)
๐ก For TypeScript Developers: Unlike Node.js modules or ES6 imports, Go's import system is more explicit and structured:
package main
declares this as an executable program (like having amain()
in C++)- All imports must be used (unused imports cause compilation errors)
- No default imports - you import specific packages by name
- Standard library packages don't need installation (unlike
npm install
)
reader := bufio.NewReader(os.Stdin)
fmt.Print("Enter your name: ")
name, _ := reader.ReadString('\n')
name = strings.TrimSpace(name)
Compare this to Node.js:
// Node.js/TypeScript approach
import readline from 'readline';
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
rl.question('Enter your name: ', (name) => {
console.log(`Hello, ${name.trim()}! Ready to manage some books?`);
rl.close();
});
๐ก For TypeScript Developers: Notice several key differences:
- Synchronous by default: Go's
ReadString
blocks until input is received, no callbacks needed- Multiple return values:
name, _ := reader.ReadString('\n')
returns both the string and an error- Explicit error ignoring: The
_
means "I'm intentionally ignoring this error" (we'll fix this later)- No automatic string methods: We need
strings.TrimSpace()
instead ofname.trim()
In TypeScript/Node.js, you might think: "Why is this so verbose? Why not just use a simple input method?"
Go's philosophy: Be explicit about what can fail. Even simple input operations can error (what if stdin is closed?), so Go makes you acknowledge this possibility, even if you choose to ignore it.
Try running this version:
go run main.go
What you'll notice:
Quick Exercise: Before moving to the next part, try breaking this code intentionally:
strings.TrimSpace()
line and see how input behavesThis first part establishes the foundation, but already shows Go's core principles: explicitness, predictability, and compile-time safety.
Now let's track a single book and introduce Go's approach to data and control flow. Before you read the code, think about how you'd structure this in TypeScript - what would your data model look like?
package main
import (
"bufio"
"fmt"
"os"
"strings"
)
func main() {
// Book data - like a simple JS object, but with explicit types
var bookTitle string = "The Go Programming Language"
var bookAuthor string = "Donovan & Kernighan"
var isAvailable bool = true
reader := bufio.NewReader(os.Stdin)
for {
fmt.Println("\n=== Library Manager ===")
fmt.Println("1. View book")
fmt.Println("2. Check out book")
fmt.Println("3. Return book")
fmt.Println("4. Exit")
fmt.Print("Choose option: ")
input, _ := reader.ReadString('\n')
choice := strings.TrimSpace(input)
switch choice {
case "1":
fmt.Printf("\nTitle: %s\n", bookTitle)
fmt.Printf("Author: %s\n", bookAuthor)
if isAvailable {
fmt.Println("Status: Available")
} else {
fmt.Println("Status: Checked Out")
}
case "2":
if isAvailable {
isAvailable = false
fmt.Println("Book checked out successfully!")
} else {
fmt.Println("Sorry, book is already checked out.")
}
case "3":
if !isAvailable {
isAvailable = true
fmt.Println("Book returned successfully!")
} else {
fmt.Println("Book is already available.")
}
case "4":
fmt.Println("Goodbye!")
return
default:
fmt.Println("Invalid option. Please try again.")
}
}
}
Let's explore what's happening here and how it compares to TypeScript:
How you might structure this in TypeScript:
interface Book {
title: string;
author: string;
isAvailable: boolean;
}
const book: Book = {
title: "The Go Programming Language",
author: "Donovan & Kernighan",
isAvailable: true
};
Go's current approach:
var bookTitle string = "The Go Programming Language"
var bookAuthor string = "Donovan & Kernighan"
var isAvailable bool = true
๐ก For TypeScript Developers: Right now we're using separate variables because we haven't introduced structs yet. This is intentionally showing you the "primitive" approach first. Notice how Go requires explicit type declarations - there's no implicit object creation like in JavaScript.
TypeScript developers often write:
if (choice === "1") {
// view book
} else if (choice === "2") {
// check out
} else if (choice === "3") {
// return
} else if (choice === "4") {
// exit
} else {
// invalid
}
Go's switch
is cleaner:
switch choice {
case "1":
// view book
case "2":
// check out
case "3":
// return
case "4":
return
default:
// invalid
}
๐ก For TypeScript Developers: Go's
switch
has important differences:
- No fall-through by default - each case automatically breaks (no need for
break
statements)- Any type can be switched on - not just numbers like in C
- No parentheses needed around the switch expression
- More readable for multiple conditions than if-else chains
for {
// menu logic
}
Compare to TypeScript:
while (true) {
// menu logic
}
๐ก For TypeScript Developers: Go's
for
is more versatile than most languages. It can be:
for { }
- infinite loop (likewhile(true)
)for condition { }
- conditional loop (likewhile(condition)
)for i := 0; i < 10; i++ { }
- traditional for loopfor index, value := range items { }
- iteration (likefor...of
)
Try This: Run the program and test all the menu options. Notice:
Thinking Exercise: Before we move to Part 3, consider:
Keep these questions in mind as we tackle multiple books in the next part using Go's slice system.
Now we tackle the limitation of our single-book system. Before diving into the code, consider this: How would you represent multiple books in TypeScript? An array of objects, right? Go has a similar concept called slices, but they work quite differently under the hood.
Let's manage multiple books using Go's slices:
package main
import (
"bufio"
"fmt"
"os"
"strings"
)
func main() {
// Slice of books - similar to JS array, but with important differences
books := []map[string]interface{}{
{"title": "The Go Programming Language", "author": "Donovan & Kernighan", "available": true},
{"title": "Clean Code", "author": "Robert Martin", "available": true},
{"title": "The Pragmatic Programmer", "author": "Hunt & Thomas", "available": false},
}
reader := bufio.NewReader(os.Stdin)
for {
fmt.Println("\n=== Library Manager ===")
fmt.Println("1. List all books")
fmt.Println("2. Search books")
fmt.Println("3. Check out book")
fmt.Println("4. Return book")
fmt.Println("5. Exit")
fmt.Print("Choose option: ")
input, _ := reader.ReadString('\n')
choice := strings.TrimSpace(input)
switch choice {
case "1":
listBooks(books)
case "2":
searchBooks(books, reader)
case "3":
checkOutBook(books, reader)
case "4":
returnBook(books, reader)
case "5":
fmt.Println("Goodbye!")
return
default:
fmt.Println("Invalid option. Please try again.")
}
}
}
func listBooks(books []map[string]interface{}) {
fmt.Println("\n=== All Books ===")
for i, book := range books {
status := "Available"
if !book["available"].(bool) {
status = "Checked Out"
}
fmt.Printf("%d. %s by %s - %s\n",
i+1, book["title"], book["author"], status)
}
}
func searchBooks(books []map[string]interface{}, reader *bufio.Reader) {
fmt.Print("Enter search term: ")
searchTerm, _ := reader.ReadString('\n')
searchTerm = strings.ToLower(strings.TrimSpace(searchTerm))
fmt.Println("\n=== Search Results ===")
found := false
for i, book := range books {
title := strings.ToLower(book["title"].(string))
author := strings.ToLower(book["author"].(string))
if strings.Contains(title, searchTerm) || strings.Contains(author, searchTerm) {
status := "Available"
if !book["available"].(bool) {
status = "Checked Out"
}
fmt.Printf("%d. %s by %s - %s\n",
i+1, book["title"], book["author"], status)
found = true
}
}
if !found {
fmt.Println("No books found matching your search.")
}
}
func checkOutBook(books []map[string]interface{}, reader *bufio.Reader) {
listBooks(books)
fmt.Print("Enter book number to check out: ")
input, _ := reader.ReadString('\n')
var bookNum int
fmt.Sscanf(strings.TrimSpace(input), "%d", &bookNum)
if bookNum < 1 || bookNum > len(books) {
fmt.Println("Invalid book number.")
return
}
book := books[bookNum-1]
if book["available"].(bool) {
book["available"] = false
fmt.Printf("'%s' checked out successfully!\n", book["title"])
} else {
fmt.Printf("'%s' is already checked out.\n", book["title"])
}
}
func returnBook(books []map[string]interface{}, reader *bufio.Reader) {
fmt.Println("\n=== Checked Out Books ===")
hasCheckedOut := false
for i, book := range books {
if !book["available"].(bool) {
fmt.Printf("%d. %s by %s\n",
i+1, book["title"], book["author"])
hasCheckedOut = true
}
}
if !hasCheckedOut {
fmt.Println("No books are currently checked out.")
return
}
fmt.Print("Enter book number to return: ")
input, _ := reader.ReadString('\n')
var bookNum int
fmt.Sscanf(strings.TrimSpace(input), "%d", &bookNum)
if bookNum < 1 || bookNum > len(books) {
fmt.Println("Invalid book number.")
return
}
book := books[bookNum-1]
if !book["available"].(bool) {
book["available"] = true
fmt.Printf("'%s' returned successfully!\n", book["title"])
} else {
fmt.Printf("'%s' is already available.\n", book["title"])
}
}
Let's compare this data structure approach:
TypeScript approach you might use:
interface Book {
title: string;
author: string;
available: boolean;
}
const books: Book[] = [
{ title: "The Go Programming Language", author: "Donovan & Kernighan", available: true },
{ title: "Clean Code", author: "Robert Martin", available: true },
{ title: "The Pragmatic Programmer", author: "Hunt & Thomas", available: false }
];
Go's current approach:
books := []map[string]interface{}{
{"title": "The Go Programming Language", "author": "Donovan & Kernighan", "available": true},
{"title": "Clean Code", "author": "Robert Martin", "available": true},
{"title": "The Pragmatic Programmer", "author": "Hunt & Thomas", "available": false},
}
๐ก For TypeScript Developers: This looks similar but works very differently:
[]map[string]interface{}
is Go's way of saying "slice of maps from string keys to any value type"interface{}
is like TypeScript'sunknown
type - you need explicit type assertions to use values- This approach is actually awkward in Go (we'll fix it with structs in part 4)
TypeScript iteration:
books.forEach((book, index) => {
console.log(`${index + 1}. ${book.title} by ${book.author}`);
});
// Or with for...of
for (const [index, book] of books.entries()) {
console.log(`${index + 1}. ${book.title} by ${book.author}`);
}
Go's range pattern:
for i, book := range books {
fmt.Printf("%d. %s by %s\n",
i+1, book["title"], book["author"])
}
๐ก For TypeScript Developers: Go's
range
gives you both index and value automatically. You can also:
for i := range books
- just get indices (likeObject.keys()
)for _, book := range books
- just get values (likeObject.values()
)- The
_
means "I don't need this value"
Notice this pattern throughout the code:
book["available"].(bool) // Type assertion
book["title"].(string) // Type assertion
Compare to TypeScript:
book.available // TypeScript knows this is boolean
book.title // TypeScript knows this is string
๐ก For TypeScript Developers: Go's type assertions are necessary because
interface{}
could hold any type. The syntaxvalue.(Type)
means "I believe this value is of Type, please convert it." If you're wrong, the program panics!
Notice our function signatures:
func listBooks(books []map[string]interface{}) {
// ...
}
func searchBooks(books []map[string]interface{}, reader *bufio.Reader) {
// ...
}
Compare to TypeScript:
function listBooks(books: Book[]): void {
// ...
}
function searchBooks(books: Book[], reader: NodeJS.ReadStream): void {
// ...
}
๐ก For TypeScript Developers:
- Go functions are explicit about all parameters - no global variables or implicit dependencies
*bufio.Reader
is a pointer - we'll dive into pointers later, but think of it as "pass by reference"- Functions that don't return values don't need explicit
void
return type
By now, you might notice this approach is getting unwieldy:
[]map[string]interface{} // This type signature is everywhere
book["title"].(string) // Type assertions everywhere
Take a moment to think:
This is exactly why we need structs! Part 4 will transform this verbose, error-prone approach into something clean and type-safe.
Try this part and notice:
Keep these pain points in mind - they'll make you appreciate Go's struct system even more in the next part.
If you felt frustrated with all those type assertions in Part 3, you're thinking like a Go developer! This part introduces structs - Go's solution to the type safety and organization problems we've been encountering.
Before you see the code, pause and think: How would you design a better Book type in TypeScript? What would it look like? Keep that mental model as we explore Go's approach.
Structs are Go's way of creating custom types - similar to TypeScript interfaces, but with more capabilities:
package main
import (
"bufio"
"fmt"
"os"
"strings"
)
// Book struct - Go's version of a TypeScript interface with methods
type Book struct {
Title string
Author string
Available bool
}
// Methods on Book struct - like class methods, but attached to the type
func (b *Book) CheckOut() bool {
if b.Available {
b.Available = false
return true
}
return false
}
func (b *Book) Return() bool {
if !b.Available {
b.Available = true
return true
}
return false
}
func (b Book) Status() string {
if b.Available {
return "Available"
}
return "Checked Out"
}
func main() {
// Initialize books with struct literals - much cleaner!
books := []Book{
{Title: "The Go Programming Language", Author: "Donovan & Kernighan", Available: true},
{Title: "Clean Code", Author: "Robert Martin", Available: true},
{Title: "The Pragmatic Programmer", Author: "Hunt & Thomas", Available: false},
}
reader := bufio.NewReader(os.Stdin)
for {
fmt.Println("\n=== Library Manager ===")
fmt.Println("1. List all books")
fmt.Println("2. Search books")
fmt.Println("3. Check out book")
fmt.Println("4. Return book")
fmt.Println("5. Add new book")
fmt.Println("6. Exit")
fmt.Print("Choose option: ")
input, _ := reader.ReadString('\n')
choice := strings.TrimSpace(input)
switch choice {
case "1":
listBooks(books)
case "2":
searchBooks(books, reader)
case "3":
checkOutBook(books, reader)
case "4":
returnBook(books, reader)
case "5":
books = addBook(books, reader)
case "6":
fmt.Println("Goodbye!")
return
default:
fmt.Println("Invalid option. Please try again.")
}
}
}
func listBooks(books []Book) {
fmt.Println("\n=== All Books ===")
for i, book := range books {
fmt.Printf("%d. %s by %s - %s\n",
i+1, book.Title, book.Author, book.Status())
}
}
func searchBooks(books []Book, reader *bufio.Reader) {
fmt.Print("Enter search term: ")
searchTerm, _ := reader.ReadString('\n')
searchTerm = strings.ToLower(strings.TrimSpace(searchTerm))
fmt.Println("\n=== Search Results ===")
found := false
for i, book := range books {
title := strings.ToLower(book.Title)
author := strings.ToLower(book.Author)
if strings.Contains(title, searchTerm) || strings.Contains(author, searchTerm) {
fmt.Printf("%d. %s by %s - %s\n",
i+1, book.Title, book.Author, book.Status())
found = true
}
}
if !found {
fmt.Println("No books found matching your search.")
}
}
func checkOutBook(books []Book, reader *bufio.Reader) {
listBooks(books)
fmt.Print("Enter book number to check out: ")
input, _ := reader.ReadString('\n')
var bookNum int
fmt.Sscanf(strings.TrimSpace(input), "%d", &bookNum)
if bookNum < 1 || bookNum > len(books) {
fmt.Println("Invalid book number.")
return
}
book := &books[bookNum-1] // Get pointer to modify original
if book.CheckOut() {
fmt.Printf("'%s' checked out successfully!\n", book.Title)
} else {
fmt.Printf("'%s' is already checked out.\n", book.Title)
}
}
func returnBook(books []Book, reader *bufio.Reader) {
fmt.Println("\n=== Checked Out Books ===")
hasCheckedOut := false
for i, book := range books {
if !book.Available {
fmt.Printf("%d. %s by %s\n",
i+1, book.Title, book.Author)
hasCheckedOut = true
}
}
if !hasCheckedOut {
fmt.Println("No books are currently checked out.")
return
}
fmt.Print("Enter book number to return: ")
input, _ := reader.ReadString('\n')
var bookNum int
fmt.Sscanf(strings.TrimSpace(input), "%d", &bookNum)
if bookNum < 1 || bookNum > len(books) {
fmt.Println("Invalid book number.")
return
}
book := &books[bookNum-1]
if book.Return() {
fmt.Printf("'%s' returned successfully!\n", book.Title)
} else {
fmt.Printf("'%s' is already available.\n", book.Title)
}
}
func addBook(books []Book, reader *bufio.Reader) []Book {
fmt.Print("Enter book title: ")
title, _ := reader.ReadString('\n')
title = strings.TrimSpace(title)
fmt.Print("Enter book author: ")
author, _ := reader.ReadString('\n')
author = strings.TrimSpace(author)
newBook := Book{
Title: title,
Author: author,
Available: true,
}
books = append(books, newBook)
fmt.Printf("Added '%s' by %s to the library!\n", title, author)
return books
}
Let's compare what just happened:
Part 3 (the painful way):
// Data definition
books := []map[string]interface{}{ /* complex initialization */ }
// Usage everywhere
book["available"].(bool) // Type assertion required
book["title"].(string) // Type assertion required
status := "Available" // Manual status logic
if !book["available"].(bool) {
status = "Checked Out"
}
Part 4 (the Go way):
// Data definition
type Book struct {
Title string
Author string
Available bool
}
// Usage everywhere
book.Available // Direct access, compile-time safe
book.Title // Direct access, compile-time safe
status := book.Status() // Method call, behavior encapsulated
๐ก For TypeScript Developers: This transformation should feel familiar! It's similar to moving from this TypeScript pattern:
// Not ideal const book: {[key: string]: any} = { title: "...", available: true }; // To this interface Book { title: string; available: boolean; } const book: Book = { title: "...", available: true };
Go's method syntax:
func (b *Book) CheckOut() bool {
if b.Available {
b.Available = false
return true
}
return false
}
Compare to TypeScript class methods:
class Book {
constructor(public title: string, public author: string, public available: boolean) {}
checkOut(): boolean {
if (this.available) {
this.available = false;
return true;
}
return false;
}
}
๐ก For TypeScript Developers: Go methods work differently:
- Methods are defined outside the struct - they're functions that take the struct as a special parameter
- Receiver types matter:
(b *Book)
means "modify the original",(b Book)
means "work with a copy"- No classes or constructors - structs are just data, methods are just functions with special syntax
- No inheritance - Go uses composition and interfaces instead
Notice this crucial difference:
func (b *Book) CheckOut() bool { // Pointer receiver - can modify
b.Available = false
}
func (b Book) Status() string { // Value receiver - read-only
return b.Available
}
In our code:
book := &books[bookNum-1] // Get pointer to slice element
if book.CheckOut() { // Now we can modify the original
fmt.Printf("'%s' checked out!\n", book.Title)
}
๐ก For TypeScript Developers: This is like the difference between:
// JavaScript/TypeScript - objects are always passed by reference function checkOut(book: Book): boolean { book.available = false; // Modifies original return true; } // Go forces you to be explicit about this intention func (b *Book) CheckOut() bool { // * means "I will modify" b.Available = false return true }
func addBook(books []Book, reader *bufio.Reader) []Book {
// ... get user input ...
newBook := Book{Title: title, Author: author, Available: true}
books = append(books, newBook) // Add to slice
return books // Return updated slice
}
Compare to TypeScript:
function addBook(books: Book[]): Book[] {
// ... get user input ...
const newBook: Book = { title, author, available: true };
books.push(newBook); // Mutates original array
return books; // Optional - array was already modified
}
๐ก For TypeScript Developers: Go's
append()
might create a new underlying array if the current one is full. That's why we need to reassign:books = append(books, newBook)
. TypeScript'spush()
always modifies the same array object.
Try this experiment: Add a typo to any field name:
book.Titel // Compiler error: "Book has no field or method 'Titel'"
Compare to our Part 3 approach:
book["titel"] // Runtime panic or unexpected behavior
๐ก For TypeScript Developers: This is why structs are so powerful - you get TypeScript-level compile-time checking, but with Go's performance and simplicity.
Notice how our function signatures cleaned up:
Part 3:
func listBooks(books []map[string]interface{})
Part 4:
func listBooks(books []Book)
The intent is now crystal clear, and we've eliminated all those fragile type assertions!
Reflection Exercise: Before moving to Part 5, consider:
Part 4 shows Go's sweet spot: the explicitness of static typing with clean, readable code. But we're still missing error handling and data persistence - that's where Part 5 takes us next.
We've been ignoring errors with _
throughout this tutorial, but that's not how real Go programs work. This part introduces Go's explicit error handling approach and adds more sophisticated data management with maps and file persistence.
Before diving in, consider: How does your TypeScript/Node.js code typically handle errors? Try-catch blocks? Promise rejections? Keep that mental model as we explore Go's different approach.
package main
import (
"bufio"
"encoding/json"
"fmt"
"io/ioutil"
"os"
"strings"
"time"
)
type Book struct {
ID int `json:"id"`
Title string `json:"title"`
Author string `json:"author"`
Available bool `json:"available"`
BorrowedBy string `json:"borrowed_by,omitempty"`
BorrowedAt time.Time `json:"borrowed_at,omitempty"`
}
type Library struct {
Books []Book `json:"books"`
Users map[string]string `json:"users"` // username -> full name
NextID int `json:"next_id"`
}
func (b *Book) CheckOut(username string) error {
if !b.Available {
return fmt.Errorf("book '%s' is already checked out", b.Title)
}
b.Available = false
b.BorrowedBy = username
b.BorrowedAt = time.Now()
return nil
}
func (b *Book) Return() error {
if b.Available {
return fmt.Errorf("book '%s' is already available", b.Title)
}
b.Available = true
b.BorrowedBy = ""
b.BorrowedAt = time.Time{}
return nil
}
func (b Book) Status() string {
if b.Available {
return "Available"
}
return fmt.Sprintf("Checked out by %s", b.BorrowedBy)
}
func main() {
library, err := loadLibrary()
if err != nil {
fmt.Printf("Error loading library: %v\n", err)
// Initialize empty library
library = &Library{
Books: []Book{},
Users: make(map[string]string),
NextID: 1,
}
}
// Add some default users if none exist
if len(library.Users) == 0 {
library.Users["alice"] = "Alice Johnson"
library.Users["bob"] = "Bob Smith"
library.Users["charlie"] = "Charlie Brown"
}
reader := bufio.NewReader(os.Stdin)
for {
fmt.Println("\n=== Library Manager ===")
fmt.Println("1. List all books")
fmt.Println("2. Search books")
fmt.Println("3. Check out book")
fmt.Println("4. Return book")
fmt.Println("5. Add new book")
fmt.Println("6. Add new user")
fmt.Println("7. List users")
fmt.Println("8. Save and exit")
fmt.Print("Choose option: ")
input, err := reader.ReadString('\n')
if err != nil {
fmt.Printf("Error reading input: %v\n", err)
continue
}
choice := strings.TrimSpace(input)
switch choice {
case "1":
library.listBooks()
case "2":
if err := library.searchBooks(reader); err != nil {
fmt.Printf("Search error: %v\n", err)
}
case "3":
if err := library.checkOutBook(reader); err != nil {
fmt.Printf("Check out error: %v\n", err)
}
case "4":
if err := library.returnBook(reader); err != nil {
fmt.Printf("Return error: %v\n", err)
}
case "5":
if err := library.addBook(reader); err != nil {
fmt.Printf("Add book error: %v\n", err)
}
case "6":
if err := library.addUser(reader); err != nil {
fmt.Printf("Add user error: %v\n", err)
}
case "7":
library.listUsers()
case "8":
if err := library.save(); err != nil {
fmt.Printf("Error saving library: %v\n", err)
} else {
fmt.Println("Library saved successfully!")
}
fmt.Println("Goodbye!")
return
default:
fmt.Println("Invalid option. Please try again.")
}
}
}
func loadLibrary() (*Library, error) {
data, err := ioutil.ReadFile("library.json")
if err != nil {
return nil, err
}
var library Library
err = json.Unmarshal(data, &library)
if err != nil {
return nil, err
}
return &library, nil
}
func (l *Library) save() error {
data, err := json.MarshalIndent(l, "", " ")
if err != nil {
return err
}
return ioutil.WriteFile("library.json", data, 0644)
}
func (l *Library) listBooks() {
if len(l.Books) == 0 {
fmt.Println("No books in the library.")
return
}
fmt.Println("\n=== All Books ===")
for i, book := range l.Books {
fmt.Printf("%d. %s by %s - %s\n",
i+1, book.Title, book.Author, book.Status())
}
}
func (l *Library) searchBooks(reader *bufio.Reader) error {
fmt.Print("Enter search term: ")
searchTerm, err := reader.ReadString('\n')
if err != nil {
return err
}
searchTerm = strings.ToLower(strings.TrimSpace(searchTerm))
if searchTerm == "" {
return fmt.Errorf("search term cannot be empty")
}
fmt.Println("\n=== Search Results ===")
found := false
for i, book := range l.Books {
title := strings.ToLower(book.Title)
author := strings.ToLower(book.Author)
if strings.Contains(title, searchTerm) || strings.Contains(author, searchTerm) {
fmt.Printf("%d. %s by %s - %s\n",
i+1, book.Title, book.Author, book.Status())
found = true
}
}
if !found {
fmt.Println("No books found matching your search.")
}
return nil
}
func (l *Library) checkOutBook(reader *bufio.Reader) error {
if len(l.Books) == 0 {
return fmt.Errorf("no books available in the library")
}
l.listBooks()
fmt.Print("Enter book number to check out: ")
input, err := reader.ReadString('\n')
if err != nil {
return err
}
var bookNum int
if _, err := fmt.Sscanf(strings.TrimSpace(input), "%d", &bookNum); err != nil {
return fmt.Errorf("invalid book number format")
}
if bookNum < 1 || bookNum > len(l.Books) {
return fmt.Errorf("book number out of range")
}
fmt.Print("Enter your username: ")
username, err := reader.ReadString('\n')
if err != nil {
return err
}
username = strings.TrimSpace(username)
if _, exists := l.Users[username]; !exists {
return fmt.Errorf("user '%s' not found. Please register first", username)
}
book := &l.Books[bookNum-1]
if err := book.CheckOut(username); err != nil {
return err
}
fmt.Printf("'%s' checked out successfully to %s!\n", book.Title, l.Users[username])
return nil
}
func (l *Library) returnBook(reader *bufio.Reader) error {
checkedOutBooks := []int{}
fmt.Println("\n=== Checked Out Books ===")
for i, book := range l.Books {
if !book.Available {
fmt.Printf("%d. %s by %s (borrowed by %s)\n",
i+1, book.Title, book.Author, book.BorrowedBy)
checkedOutBooks = append(checkedOutBooks, i)
}
}
if len(checkedOutBooks) == 0 {
return fmt.Errorf("no books are currently checked out")
}
fmt.Print("Enter book number to return: ")
input, err := reader.ReadString('\n')
if err != nil {
return err
}
var bookNum int
if _, err := fmt.Sscanf(strings.TrimSpace(input), "%d", &bookNum); err != nil {
return fmt.Errorf("invalid book number format")
}
if bookNum < 1 || bookNum > len(l.Books) {
return fmt.Errorf("book number out of range")
}
book := &l.Books[bookNum-1]
if err := book.Return(); err != nil {
return err
}
fmt.Printf("'%s' returned successfully!\n", book.Title)
return nil
}
func (l *Library) addBook(reader *bufio.Reader) error {
fmt.Print("Enter book title: ")
title, err := reader.ReadString('\n')
if err != nil {
return err
}
title = strings.TrimSpace(title)
if title == "" {
return fmt.Errorf("book title cannot be empty")
}
fmt.Print("Enter book author: ")
author, err := reader.ReadString('\n')
if err != nil {
return err
}
author = strings.TrimSpace(author)
if author == "" {
return fmt.Errorf("book author cannot be empty")
}
newBook := Book{
ID: l.NextID,
Title: title,
Author: author,
Available: true,
}
l.Books = append(l.Books, newBook)
l.NextID++
fmt.Printf("Added '%s' by %s to the library!\n", title, author)
return nil
}
func (l *Library) addUser(reader *bufio.Reader) error {
fmt.Print("Enter username: ")
username, err := reader.ReadString('\n')
if err != nil {
return err
}
username = strings.TrimSpace(username)
if username == "" {
return fmt.Errorf("username cannot be empty")
}
if _, exists := l.Users[username]; exists {
return fmt.Errorf("user '%s' already exists", username)
}
fmt.Print("Enter full name: ")
fullName, err := reader.ReadString('\n')
if err != nil {
return err
}
fullName = strings.TrimSpace(fullName)
if fullName == "" {
return fmt.Errorf("full name cannot be empty")
}
l.Users[username] = fullName
fmt.Printf("Added user '%s' (%s) successfully!\n", username, fullName)
return nil
}
func (l *Library) listUsers() {
if len(l.Users) == 0 {
fmt.Println("No users registered.")
return
}
fmt.Println("\n=== Registered Users ===")
for username, fullName := range l.Users {
fmt.Printf("- %s (%s)\n", fullName, username)
}
}
This part represents a major shift in thinking. Let's break down the key differences:
TypeScript's typical approach:
class LibraryService {
async loadLibrary(): Promise<Library> {
try {
const data = await fs.readFile('library.json', 'utf-8');
return JSON.parse(data);
} catch (error) {
throw new Error(`Failed to load library: ${error.message}`);
}
}
async checkOutBook(bookId: number, username: string): Promise<void> {
if (!this.users.has(username)) {
throw new Error('User not found');
}
// ... more logic
}
}
Go's explicit approach:
func loadLibrary() (*Library, error) {
data, err := ioutil.ReadFile("library.json")
if err != nil {
return nil, err // Return error, let caller decide what to do
}
var library Library
err = json.Unmarshal(data, &library)
if err != nil {
return nil, err // Again, explicit error return
}
return &library, nil // Success case
}
func (l *Library) checkOutBook(reader *bufio.Reader) error {
// ... input validation ...
if _, exists := l.Users[username]; !exists {
return fmt.Errorf("user '%s' not found. Please register first", username)
}
// ... more logic
return nil // Success
}
๐ก For TypeScript Developers: The key differences in Go's error handling:
- Errors are values, not exceptions - they're returned, not thrown
- Every potential error is visible in function signatures - no hidden throws
- Callers must explicitly handle or ignore errors - no accidental error swallowing
- Error propagation is explicit - you choose whether to handle, wrap, or pass up errors
TypeScript approach:
interface Users {
[username: string]: string; // username -> full name
}
const users: Users = {
"alice": "Alice Johnson",
"bob": "Bob Smith"
};
// Usage
if (users[username]) { // Truthy check
console.log(`Welcome, ${users[username]}`);
}
Go's approach:
type Library struct {
Users map[string]string // username -> full name
}
// Initialization
library := &Library{
Users: make(map[string]string), // Must initialize maps!
}
// Usage
if fullName, exists := library.Users[username]; exists { // Explicit existence check
fmt.Printf("Welcome, %s\n", fullName)
}
๐ก For TypeScript Developers: Go maps require more explicitness:
- Must be initialized with
make()
before use (no automatic creation)- Existence checks are explicit using the two-value form:
value, exists := map[key]
- Zero values are returned for missing keys - empty string for
string
values- Type-safe - both keys and values have fixed types
type Book struct {
ID int `json:"id"`
Title string `json:"title"`
Author string `json:"author"`
Available bool `json:"available"`
BorrowedBy string `json:"borrowed_by,omitempty"` // Omit if empty
BorrowedAt time.Time `json:"borrowed_at,omitempty"`
}
Compare to TypeScript:
// TypeScript - serialization "just works" but no control
interface Book {
id: number;
title: string;
author: string;
available: boolean;
borrowedBy?: string; // Optional field
borrowedAt?: Date;
}
// JSON.stringify automatically handles this
๐ก For TypeScript Developers: Go's struct tags give you fine-grained control:
json:"field_name"
- customize JSON field namesomitempty
- exclude field if it has zero value-
- completely exclude field from JSON- Custom serialization logic is possible but more verbose
Notice how our methods now return errors:
func (b *Book) CheckOut(username string) error {
if !b.Available {
return fmt.Errorf("book '%s' is already checked out", b.Title)
}
b.Available = false
b.BorrowedBy = username
b.BorrowedAt = time.Now()
return nil // nil means "no error"
}
This is Go's way of handling what might be exceptions in other languages.
Reflection Exercise: Before moving to the final part:
Part 5 introduces Go's mature patterns: explicit error handling, structured data with maps, and persistent storage. Part 6 will show you Go's secret weapon: effortless concurrency.
This is where Go truly shines and differentiates itself from most other languages. Before we dive into the code, let's understand what makes Go's concurrency model so special.
What are Goroutines? Goroutines are Go's approach to concurrent execution - think of them as extremely lightweight threads that are managed by the Go runtime, not the operating system.
How do they compare to what you know?
In JavaScript/Node.js:
// Async operations
async function processFile(filename) {
const data = await fs.readFile(filename);
return processData(data);
}
// Promise.all for concurrent operations
const results = await Promise.all([
processFile('file1.txt'),
processFile('file2.txt'),
processFile('file3.txt')
]);
In Go with Goroutines:
// Goroutine - concurrent function execution
go processFile(filename) // Just add 'go' keyword!
// Multiple goroutines
go processFile("file1.txt")
go processFile("file2.txt")
go processFile("file3.txt")
๐ก Key Differences:
- Goroutines are NOT threads - they're much lighter (2KB stack vs 8MB for OS threads)
- Managed by Go runtime - Go's scheduler multiplexes goroutines onto OS threads
- No callback hell or Promise chains - use channels for communication instead
- True parallelism - can utilize multiple CPU cores simultaneously
What are Channels? Channels are Go's way of allowing goroutines to communicate with each other safely. Think of them as typed message pipes.
The Go Philosophy: "Don't communicate by sharing memory; share memory by communicating"
Compare these approaches:
Traditional Thread Communication (like in Java/C++):
// Shared memory with locks (error-prone)
class Counter {
private int count = 0;
private final Object lock = new Object();
public void increment() {
synchronized(lock) { // Manual locking
count++;
}
}
}
Go Channel Communication:
// Channels for safe communication
ch := make(chan int) // Create a channel
go func() {
ch <- 42 // Send value to channel
}()
value := <-ch // Receive value from channel
Channel Types:
make(chan Type)
- synchronous, sender blocks until receiver is readymake(chan Type, capacity)
- asynchronous up to buffer sizechan<- Type
(send-only), <-chan Type
(receive-only)Now let's see these concepts in action with our library system. We'll add features that naturally benefit from concurrency:
package main
import (
"bufio"
"encoding/json"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
type Book struct {
ID int `json:"id"`
Title string `json:"title"`
Author string `json:"author"`
Available bool `json:"available"`
BorrowedBy string `json:"borrowed_by,omitempty"`
BorrowedAt time.Time `json:"borrowed_at,omitempty"`
}
type Library struct {
Books []Book `json:"books"`
Users map[string]string `json:"users"`
NextID int `json:"next_id"`
mutex sync.RWMutex // For safe concurrent access to shared data
}
type ImportResult struct {
Success bool
Message string
Book Book
Error error
}
// ... [Previous methods remain the same - loadLibrary, save, etc.] ...
func main() {
library, err := loadLibrary()
if err != nil {
fmt.Printf("Error loading library: %v\n", err)
library = &Library{
Books: []Book{},
Users: make(map[string]string),
NextID: 1,
}
}
if len(library.Users) == 0 {
library.Users["alice"] = "Alice Johnson"
library.Users["bob"] = "Bob Smith"
library.Users["charlie"] = "Charlie Brown"
}
reader := bufio.NewReader(os.Stdin)
for {
fmt.Println("\n=== Advanced Library Manager ===")
fmt.Println("1. List all books")
fmt.Println("2. Search books")
fmt.Println("3. Check out book")
fmt.Println("4. Return book")
fmt.Println("5. Add new book")
fmt.Println("6. Add new user")
fmt.Println("7. List users")
fmt.Println("8. Import books from files (concurrent) ๐ฅ")
fmt.Println("9. Generate reports (concurrent) ๐")
fmt.Println("10. Bulk search across files (concurrent) ๐")
fmt.Println("11. Save and exit")
fmt.Print("Choose option: ")
input, err := reader.ReadString('\n')
if err != nil {
fmt.Printf("Error reading input: %v\n", err)
continue
}
choice := strings.TrimSpace(input)
switch choice {
case "1":
library.listBooks()
case "2":
if err := library.searchBooks(reader); err != nil {
fmt.Printf("Search error: %v\n", err)
}
case "3":
if err := library.checkOutBook(reader); err != nil {
fmt.Printf("Check out error: %v\n", err)
}
case "4":
if err := library.returnBook(reader); err != nil {
fmt.Printf("Return error: %v\n", err)
}
case "5":
if err := library.addBook(reader); err != nil {
fmt.Printf("Add book error: %v\n", err)
}
case "6":
if err := library.addUser(reader); err != nil {
fmt.Printf("Add user error: %v\n", err)
}
case "7":
library.listUsers()
case "8":
if err := library.importBooksFromFiles(reader); err != nil {
fmt.Printf("Import error: %v\n", err)
}
case "9":
if err := library.generateReports(reader); err != nil {
fmt.Printf("Report error: %v\n", err)
}
case "10":
if err := library.bulkSearchFiles(reader); err != nil {
fmt.Printf("Bulk search error: %v\n", err)
}
case "11":
if err := library.save(); err != nil {
fmt.Printf("Error saving library: %v\n", err)
} else {
fmt.Println("Library saved successfully!")
}
fmt.Println("Goodbye!")
return
default:
fmt.Println("Invalid option. Please try again.")
}
}
}
// CONCURRENCY PATTERN 1: Worker Pool with Channels
func (l *Library) importBooksFromFiles(reader *bufio.Reader) error {
fmt.Print("Enter directory path containing book files (JSON): ")
dirPath, err := reader.ReadString('\n')
if err != nil {
return err
}
dirPath = strings.TrimSpace(dirPath)
// Find all JSON files
files, err := filepath.Glob(filepath.Join(dirPath, "*.json"))
if err != nil {
return err
}
if len(files) == 0 {
return fmt.Errorf("no JSON files found in directory")
}
fmt.Printf("Found %d files. Processing with concurrent workers... โก\n", len(files))
// Create channels for communication
jobs := make(chan string, len(files)) // Buffered channel for file paths
results := make(chan ImportResult, len(files)) // Buffered channel for results
// Start worker goroutines (worker pool pattern)
const numWorkers = 3
for w := 1; w <= numWorkers; w++ {
go l.fileImportWorker(w, jobs, results) // Each worker is a goroutine
}
// Send work to workers through jobs channel
for _, file := range files {
jobs <- file
}
close(jobs) // Signal no more jobs
// Collect results from all workers
successCount := 0
for r := 0; r < len(files); r++ {
result := <-results // Receive from results channel
fmt.Printf("๐ %s\n", result.Message)
if result.Success {
successCount++
}
}
fmt.Printf("\nโ
Import complete! Successfully processed %d out of %d files.\n",
successCount, len(files))
return nil
}
// Worker function - runs as a goroutine
func (l *Library) fileImportWorker(id int, jobs <-chan string, results chan<- ImportResult) {
// Each worker processes jobs from the channel until it's closed
for filepath := range jobs {
fmt.Printf("Worker %d processing %s... ๐\n", id, filepath)
// Simulate processing time (remove in real implementation)
time.Sleep(time.Millisecond * 200)
result := l.processBookFile(filepath)
results <- result // Send result back through channel
}
}
func (l *Library) processBookFile(filename string) ImportResult {
data, err := ioutil.ReadFile(filename)
if err != nil {
return ImportResult{
Success: false,
Message: fmt.Sprintf("โ Error reading %s: %v", filepath.Base(filename), err),
Error: err,
}
}
var book Book
err = json.Unmarshal(data, &book)
if err != nil {
return ImportResult{
Success: false,
Message: fmt.Sprintf("โ Error parsing %s: %v", filepath.Base(filename), err),
Error: err,
}
}
// Thread-safe addition to library using mutex
l.mutex.Lock()
book.ID = l.NextID
l.NextID++
book.Available = true
l.Books = append(l.Books, book)
l.mutex.Unlock()
return ImportResult{
Success: true,
Message: fmt.Sprintf("โ
Imported: %s by %s from %s",
book.Title, book.Author, filepath.Base(filename)),
Book: book,
}
}
// CONCURRENCY PATTERN 2: Fan-out/Fan-in with Goroutines
func (l *Library) generateReports(reader *bufio.Reader) error {
fmt.Println("Generating multiple reports concurrently... ๐")
// Channel to coordinate goroutines completion
done := make(chan string, 3) // Buffered channel for completion messages
// Launch multiple report generators concurrently
go l.generateInventoryReport(done)
go l.generateUserActivityReport(done)
go l.generateOverdueReport(done)
// Wait for all reports to complete and show progress
for i := 0; i < 3; i++ {
message := <-done // Block until a report finishes
fmt.Println(message)
}
fmt.Println("๐ All reports generated successfully!")
return nil
}
func (l *Library) generateInventoryReport(done chan<- string) {
// Read-only access to library data
l.mutex.RLock()
defer l.mutex.RUnlock()
// Simulate report generation time
time.Sleep(time.Second * 1)
available := 0
checkedOut := 0
for _, book := range l.Books {
if book.Available {
available++
} else {
checkedOut++
}
}
report := fmt.Sprintf(`
=== INVENTORY REPORT ===
Total Books: %d
Available: %d
Checked Out: %d
Generated at: %s
`, len(l.Books), available, checkedOut, time.Now().Format("2006-01-02 15:04:05"))
ioutil.WriteFile("inventory_report.txt", []byte(report), 0644)
done <- "๐ Inventory report saved to inventory_report.txt"
}
func (l *Library) generateUserActivityReport(done chan<- string) {
l.mutex.RLock()
defer l.mutex.RUnlock()
time.Sleep(time.Millisecond * 800)
userActivity := make(map[string]int)
for _, book := range l.Books {
if !book.Available {
userActivity[book.BorrowedBy]++
}
}
report := "=== USER ACTIVITY REPORT ===\n"
for username, count := range userActivity {
if fullName, exists := l.Users[username]; exists {
report += fmt.Sprintf("%s (%s): %d books borrowed\n",
fullName, username, count)
}
}
report += fmt.Sprintf("Generated at: %s\n",
time.Now().Format("2006-01-02 15:04:05"))
ioutil.WriteFile("user_activity_report.txt", []byte(report), 0644)
done <- "๐ฅ User activity report saved to user_activity_report.txt"
}
func (l *Library) generateOverdueReport(done chan<- string) {
l.mutex.RLock()
defer l.mutex.RUnlock()
time.Sleep(time.Millisecond * 600)
report := "=== OVERDUE BOOKS REPORT ===\n"
overdueCount := 0
for _, book := range l.Books {
if !book.Available {
daysBorrowed := int(time.Since(book.BorrowedAt).Hours() / 24)
if daysBorrowed > 14 { // Consider overdue after 14 days
report += fmt.Sprintf("%s by %s - borrowed by %s (%d days ago)\n",
book.Title, book.Author, book.BorrowedBy, daysBorrowed)
overdueCount++
}
}
}
if overdueCount == 0 {
report += "No overdue books found! ๐\n"
}
report += fmt.Sprintf("Generated at: %s\n",
time.Now().Format("2006-01-02 15:04:05"))
ioutil.WriteFile("overdue_report.txt", []byte(report), 0644)
done <- "โฐ Overdue report saved to overdue_report.txt"
}
// CONCURRENCY PATTERN 3: Pipeline Processing
func (l *Library) bulkSearchFiles(reader *bufio.Reader) error {
fmt.Print("Enter directory to search in: ")
dirPath, err := reader.ReadString('\n')
if err != nil {
return err
}
dirPath = strings.TrimSpace(dirPath)
fmt.Print("Enter search term: ")
searchTerm, err := reader.ReadString('\n')
if err != nil {
return err
}
searchTerm = strings.ToLower(strings.TrimSpace(searchTerm))
files, err := filepath.Glob(filepath.Join(dirPath, "*.txt"))
if err != nil {
return err
}
if len(files) == 0 {
return fmt.Errorf("no text files found in directory")
}
fmt.Printf("Searching %d files for '%s'... ๐\n", len(files), searchTerm)
// Pipeline: files -> file contents -> search results
fileCh := make(chan string)
contentCh := make(chan FileContent)
resultCh := make(chan SearchResult)
// Stage 1: Send file paths
go func() {
defer close(fileCh)
for _, file := range files {
fileCh <- file
}
}()
// Stage 2: Read file contents (multiple workers)
const numReaders = 2
for i := 0; i < numReaders; i++ {
go l.fileReader(fileCh, contentCh)
}
// Stage 3: Search in contents (multiple workers)
const numSearchers = 2
for i := 0; i < numSearchers; i++ {
go l.fileSearcher(contentCh, resultCh, searchTerm)
}
// Close contentCh when all readers are done
go func() {
// This goroutine waits for all readers to finish
// In a real implementation, you'd use sync.WaitGroup
time.Sleep(time.Second * 2)
close(contentCh)
}()
// Collect results
totalMatches := 0
for result := range resultCh {
if result.Matches > 0 {
fmt.Printf("๐ %s: %d matches\n", result.Filename, result.Matches)
totalMatches += result.Matches
}
// Break when all files processed (simplified)
if totalMatches > 0 || len(files) == 1 {
break
}
}
fmt.Printf("๐ฏ Search complete! Found %d total matches.\n", totalMatches)
return nil
}
type FileContent struct {
Filename string
Content string
Error error
}
type SearchResult struct {
Filename string
Matches int
Error error
}
func (l *Library) fileReader(files <-chan string, contents chan<- FileContent) {
for filename := range files {
data, err := ioutil.ReadFile(filename)
contents <- FileContent{
Filename: filename,
Content: string(data),
Error: err,
}
}
}
func (l *Library) fileSearcher(contents <-chan FileContent, results chan<- SearchResult, searchTerm string) {
for content := range contents {
if content.Error != nil {
results <- SearchResult{
Filename: content.Filename,
Matches: 0,
Error: content.Error,
}
continue
}
matches := strings.Count(strings.ToLower(content.Content), searchTerm)
results <- SearchResult{
Filename: filepath.Base(content.Filename),
Matches: matches,
}
}
}
// Include all previous methods (listBooks, searchBooks, etc.) here...
// [Previous methods from Version 5 remain the same]
Pattern 1: Worker Pool ๐
// Create channels
jobs := make(chan string, len(files)) // Work queue
results := make(chan ImportResult, len(files)) // Result collection
// Start workers
for w := 1; w <= numWorkers; w++ {
go l.fileImportWorker(w, jobs, results) // Each worker is a goroutine
}
// Distribute work
for _, file := range files {
jobs <- file // Send work to any available worker
}
๐ก For Other Language Developers: This is like having a thread pool, but much more lightweight. Each goroutine uses only ~2KB of memory vs ~8MB for OS threads.
Pattern 2: Fan-out/Fan-in ๐
// Start multiple concurrent operations
go l.generateInventoryReport(done)
go l.generateUserActivityReport(done)
go l.generateOverdueReport(done)
// Wait for all to complete
for i := 0; i < 3; i++ {
message := <-done // Blocks until one completes
fmt.Println(message)
}
Pattern 3: Pipeline Processing ๐
// files -> contents -> results (like Unix pipes)
fileCh := make(chan string) // Stage 1
contentCh := make(chan FileContent) // Stage 2
resultCh := make(chan SearchResult) // Stage 3
// Each stage processes data and passes to next stage
go fileProducer(fileCh)
go fileReader(fileCh, contentCh)
go fileSearcher(contentCh, resultCh)
type Library struct {
Books []Book
mutex sync.RWMutex // Protects shared data
}
func (l *Library) addBookConcurrently(book Book) {
l.mutex.Lock() // Exclusive lock for writing
l.Books = append(l.Books, book)
l.mutex.Unlock()
}
func (l *Library) countBooks() int {
l.mutex.RLock() // Shared lock for reading
defer l.mutex.RUnlock() // Defer ensures unlock happens
return len(l.Books)
}
Performance Comparison:
Memory Efficiency:
๐ก The Go Advantage: You can easily run thousands of goroutines simultaneously, something impossible with traditional threads. This enables new architectural patterns and makes concurrent programming accessible to more developers.
This version showcases Go's concurrency superpowers - patterns that would be complex and error-prone in most other languages become elegant and readable in Go! โก
Everything we've done so far has been in development mode using go run
. For production, you'll want to build a standalone binary.
To build your application:
go build -o library-manager
This creates a single executable file with no dependencies (unlike Node.js apps that need the runtime).
Go's best feature for deployment - you can build for any platform:
# For Windows from Mac/Linux:
GOOS=windows GOARCH=amd64 go build -o library-manager.exe
# For Linux from Mac/Windows:
GOOS=linux GOARCH=amd64 go build -o library-manager
# For Mac from Linux/Windows:
GOOS=darwin GOARCH=amd64 go build -o library-manager
Unlike Node.js apps, Go binaries are completely self-contained:
# Just copy the binary - no package.json, no node_modules!
scp library-manager user@server:/usr/local/bin/
After building this library management system, you should now see Go's key advantages:
You now have a solid foundation in Go! Here are some areas to explore next:
net/http
package for building web serverscontext
package for cancellationsync
package primitivesgo test
)database/sql
gorm
for ORMsThe Go community is welcoming and the documentation is excellent. As a JS/TS developer, you already have the programming fundamentals - Go just gives you a different, powerful tool for building reliable, performant applications.
View the complete source code: GitHub Repository
I hope this tutorial has given you a solid introduction to Go and shown you why it might be a valuable addition to your toolkit! ๐ The transition from JavaScript/TypeScript to Go can feel different at first, but the performance, reliability, and deployment simplicity make it worth the investment.
Let me know if anything was unclear, or if there are specific Go topics you'd like to explore further! ๐ฌ
If you found this content helpful, consider buying me a coffee. Your support helps me create more free content!
8 views