Skip to main content
Version: v2.2.82 (latest)
Concepts · 05 / 06

Higher-Kinded Types.

Understand how fp-go simulates higher-kinded types in Go. Learn the patterns, limitations, and practical approaches for generic functional programming.

// Go's limitation
No type constructor parameters.
// fp-go's solution
Consistent API across all types.
// Trade-off
Some duplication for simplicity.
01

What are HKTs?

Higher-Kinded Types (HKTs) are types that take other types as parameters.

Regular Types (Kind *)

regular-types.go
// Regular types - concrete
int           // A number
string        // Text
User          // A struct

Generic Types (Kind * → *)

generic-types.go
// Generic types - take one type parameter
Option[int]      // Option of int
Result[string]   // Result of string
Array[User]      // Array of User

// The type constructor:
Option[_]        // Takes a type, returns a type
Result[_]        // Takes a type, returns a type

Higher-Kinded Types (Kind (* → *) → *)

hkts.go
// HKTs - take a type constructor as parameter
// (This is what Go doesn't support)

// Hypothetical syntax:
Functor[F[_]]    // F is a type constructor
Monad[M[_]]      // M is a type constructor

// Would let us write:
func Map[F[_], A, B](f func(A) B) func(F[A]) F[B]

// Works for ANY F: Option, Result, Array, etc.
02

Why Go doesn't have HKTs.

Go's Type System

Go 1.18+ has generics, but they're limited:

After
go-can-do.go
// ✅ Can do: concrete type parameters
func Map[A, B any](f func(A) B, slice []A) []B
Before
go-cannot-do.go
// ❌ Can't do: type constructor parameters
func Map[F[_], A, B any](f func(A) B, fa F[A]) F[B]
//         ^^^
//         Not allowed in Go

Why?

  • Simpler type system
  • Easier to implement
  • Faster compilation
  • Go philosophy: simplicity over power
03

How fp-go works around this.

fp-go uses several techniques to simulate HKTs:

Technique 1: Separate Functions per Type

Instead of one generic Map, we have:

separate-functions.go
// Option.Map
func Map[B, A any](f func(A) B) func(Option[A]) Option[B]

// Result.Map
func Map[B, A any](f func(A) B) func(Result[A]) Result[B]

// Array.Map
func Map[B, A any](f func(A) B) func([]A) []B

// IO.Map
func Map[B, A any](f func(A) B) func(IO[A]) IO[B]

Pros:

  • ✅ Works in Go
  • ✅ Type-safe
  • ✅ Clear which type you're using

Cons:

  • ⚠️ Code duplication
  • ⚠️ Can't write generic algorithms

Technique 2: Consistent API

All types follow the same pattern:

consistent-api.go
// Every monad has these functions:
Of[A](a A) M[A]                              // Put value in monad
Map[B, A](f func(A) B) func(M[A]) M[B]       // Transform value
Chain[B, A](f func(A) M[B]) func(M[A]) M[B]  // Chain operations
Fold[B, A](/* ... */) func(M[A]) B           // Extract value

// Example with Option:
option.Of(42)
option.Map(func(x int) int { return x * 2 })
option.Chain(func(x int) option.Option[int] { return option.Some(x) })
option.Fold(func() int { return 0 }, func(x int) int { return x })

// Example with Result:
result.Ok(42)
result.Map(func(x int) int { return x * 2 })
result.Chain(func(x int) result.Result[int] { return result.Ok(x) })
result.Fold(func(err error) int { return 0 }, func(x int) int { return x })

Benefit: Learn once, use everywhere.

Technique 3: Code Generation

fp-go uses code generation to create similar functions for each type:

codegen.go
// Generated from template
//go:generate go run gen.go

// Generates:
// - option/map.go
// - result/map.go
// - array/map.go
// - etc.

Benefit: Consistency without manual duplication.

04

Understanding type parameters.

Type Parameter Order in fp-go v2

fp-go v2 reordered type parameters for better inference:

Before
v1-ordering.go
// v1: inferrable parameters first
func Map[A, B any](f func(A) B) func(Option[A]) Option[B]
//       ^  ^
//       |  Can't infer B from function signature
//       Can infer A from function argument
After
v2-ordering.go
// v2: non-inferrable parameters first
func Map[B, A any](f func(A) B) func(Option[A]) Option[B]
//       ^  ^
//       |  Can infer A from function argument
//       Can't infer B, so comes first

Why? Go can infer trailing type parameters but not leading ones.

Example

inference-example.go
// With v2 ordering
opt := option.Some(5)

// Go can infer types
doubled := option.Map(func(x int) string {
  return strconv.Itoa(x * 2)
})(opt)
// Go infers: Map[string, int]
//                 ^      ^
//                 B      A (from function)

// No need to specify:
// option.Map[string, int](...)
05

Practical implications.

1. Learn the Pattern Once

All fp-go types follow the same pattern:

pattern.go
// Pattern for any monad M:

// Create
M.Of(value)              // or M.Some, M.Ok, etc.

// Transform
M.Map(transform)         // Change the value

// Chain
M.Chain(operation)       // Sequence operations

// Extract
M.Fold(onError, onSuccess)  // Get the value out

2. Use Type-Specific Functions

Before
generic-impossible.go
// Can't write generic code like:
func Process[M[_], A, B](m M[A], f func(A) B) M[B] {
  return M.Map(f)(m)  // Not possible in Go
}
After
specific-functions.go
// Instead, write specific functions:
func ProcessOption[A, B](opt option.Option[A], f func(A) B) option.Option[B] {
  return option.Map(f)(opt)
}

func ProcessResult[A, B](res result.Result[A], f func(A) B) result.Result[B] {
  return result.Map(f)(res)
}

3. Embrace Go's Simplicity

Instead of fighting Go's type system, work with it:

  • ✅ Use specific types
  • ✅ Accept some duplication
  • ✅ Focus on clarity
06

Comparing with other languages.

Haskell (Has HKTs)

haskell.hs
-- Generic map for any Functor
fmap :: Functor f => (a -> b) -> f a -> f b

-- Works for all:
fmap (+1) (Just 5)        -- Maybe Int
fmap (+1) [1,2,3]         -- List Int
fmap (+1) (Right 5)       -- Either e Int

TypeScript (Simulates HKTs)

typescript.ts
// Type-level programming
interface Functor<F> {
map<A, B>(f: (a: A) => B): (fa: F<A>) => F<B>
}

// Works for Option, Result, Array, etc.

Go (No HKTs)

go-approach.go
// Separate functions
option.Map[B, A](f func(A) B) func(option.Option[A]) option.Option[B]
result.Map[B, A](f func(A) B) func(result.Result[A]) result.Result[B]
array.Map[B, A](f func(A) B) func([]A) []B

// More verbose, but simpler
07

Practical advice.

1. Don't Fight the Type System

Before
too-generic.go
// ❌ Don't try to be too generic
func Process[???](m ???) ??? {
  // Impossible in Go
}
After
specific-clear.go
// ✅ Write specific, clear code
func ProcessUser(res result.Result[User]) result.Result[UserDTO] {
  return result.Map(toDTO)(res)
}

2. Embrace Duplication When Needed

duplication-ok.go
// Some duplication is okay
func ProcessUsers(users []User) []UserDTO {
  return array.Map(toDTO)(users)
}

func ProcessUserResult(res result.Result[User]) result.Result[UserDTO] {
  return result.Map(toDTO)(res)
}

// Clear and type-safe

3. Use Consistent Patterns

consistent-patterns.go
// Learn the pattern once
// Apply to all types

// Option
option.Map(f)(opt)
option.Chain(g)(opt)

// Result
result.Map(f)(res)
result.Chain(g)(res)

// Array
array.Map(f)(arr)
array.Chain(g)(arr)

4. Focus on Value

HKTs are a means, not an end. Focus on:

  • Clear code
  • Type safety
  • Composability
  • Maintainability

Not on:

  • Maximum abstraction
  • Minimal duplication
  • Theoretical purity