类型、别名与边界

作者:lanran 发布于:2026年6月1日

名字、边界与类型的身份

目录 9 节
  1. 类型别名:名字变了,类型没变
  2. Go
  3. Rust
  4. Scala
  5. 不透明类型:名字开始变成边界
  6. Go
  7. Rust
  8. Scala
  9. 横向看:名字、身份和边界

“Die Grenzen meiner Sprache bedeuten die Grenzen meiner Welt.” — Ludwig Wittgenstein

“语言是思维的边界”,类型是业务的边界。

比如一个很普通的问题:

UserId 和 String 是不是同一个类型?

如果只是为了让代码更好读,我们可能会说:给 String 起个名字吧,叫 UserId。这样函数签名里出现 UserId,读的人马上知道这里不是随便一段文本。

但如果我们想让编译器能够区分呢?

比如 UserIdOrderIdEmail 底层都可以是字符串,但它们不应该被混用。这时候“起名字”就不够了。名字要开始变成边界,甚至变成一种小小的封装。

所以类型别名这件事可以分成两层来看:

第一层是类型别名:给已有类型换一个名字。

第二层是不透明类型 / newtype / defined type 这一类东西:底层表示可能没变,但外面看起来已经不是同一个类型了。

前者解决的是表达问题,后者解决的是约束问题

类型别名:名字变了,类型没变

类型别名最朴素的动机,是让类型签名更像人在说话。

String -> UserId
Map[String, List[Int]] -> Index
Result<T, io::Error> -> io::Result<T>

它不一定让类型系统变得更强,但会让代码少一点噪声。问题在于:别名毕竟只是别名,它通常不会创造新的类型身份。

Go

Go 里这个区别很直观,因为它把两种写法摆在了你面前。

type UserId = string

这叫 type alias。UserIdstring 是同一个类型,只是多了一个名字。

type UserId = string

func FindUser(id string) {}

func main() {
	var id UserId = "u_123"
	FindUser(id) // ok
}

这段代码里,id 虽然标成了 UserId,但传给需要 string 的函数没有任何问题。因为在类型系统眼里,它们并没有分开。

所以 Go 的 alias 更像是“给已有类型开了一个入口”。它适合做重构、迁移、兼容旧包名,也适合把复杂类型写短一点。

比如:

type StringSet = map[string]bool

这会让代码看起来更有意图,但它不会阻止你把普通的 map[string]bool 当成 StringSet 用。这里没有新的类型边界。

Go 真正会创造新类型的是另一种写法:

type UserId string

这个后面再说。Go 在这里有意思的地方正是:差一个 =,语义就从“名字”变成了“类型”。

Rust

Rust 里也有 type alias。

type UserId = String;

它同样不创造新类型。

type UserId = String;

fn find_user(id: String) {}

fn main() {
    let id: UserId = String::from("u_123");
    find_user(id); // ok
}

UserId 只是 String 的另一个名字。函数要的是 String,你给它 UserId,编译器不会觉得这是两种东西。

Rust 里的别名很常见,尤其适合处理长类型。

type Thunk = Box<dyn Fn() + Send + 'static>;

或者给 Result 填好错误类型:

type DbResult<T> = Result<T, DbError>;

这类别名很好用,因为 Rust 的类型签名经常会越长越认真。别名能把重复的结构折起来,让 API 暴露的是一个更有业务含义的名字。

但它的边界仍然很薄。DbResult<T> 不是一种新的 ResultUserId 也不是一种新的 String。你只是给一个类型表达式贴了标签。

Scala

Scala 的 type alias 更像是“给类型表达式起名”。

type UserId = String
type Index = Map[String, List[Int]]
type Handler[A] = A => Either[String, Unit]

Scala 的类型表达式本来就可以很丰富:函数类型、泛型类型、路径依赖类型、结构化的类型组合。于是 alias 的价值也很自然:别让签名把人淹了。

比如:

type UserId = String

def findUser(id: String): Unit = ()

val id: UserId = "u_123"
findUser(id) // ok

这里 UserId 依然没有变成一个新的类型。它只是让读者知道:这个字符串在当前语境里扮演的是用户 ID。

Scala 里 alias 的味道会比 Go/Rust 更“类型语言”一点,因为它不只是给基本类型起小名,还经常用来给复杂的抽象形状命名。

type Result[A] = Either[DomainError, A]
type Reader[Env, A] = Env => A

这很舒服,但也要记住:舒服不等于安全。type UserId = String 不能阻止你把任何字符串传进去。它让代码更会说话,却没有让编译器更严格。

不透明类型:名字开始变成边界

如果只是为了可读性,类型别名已经够了。

但很多时候,我们想表达的是另一件事:

UserId 的底层可以是 String,
但外部代码不应该把任意 String 当成 UserId。

这时候我们需要的就不只是名字,而是边界。

这个边界在不同语言里长得不一样。Scala 3 有很直接的 opaque type;Rust 常用 newtype pattern;Go 则用 defined type 给已有底层类型造一个新的命名类型。

它们不是完全同一种机制,但都在回答同一个问题:我能不能在不改变底层表示的情况下,让类型系统多认出一个概念?

Go

Go 的这行代码不是别名:

type UserId string

它定义了一个新的类型。底层类型是 string,但 UserIdstring 不再是同一个类型。

type UserId string

func FindUser(id UserId) {}

func main() {
	var raw string = "u_123"
	FindUser(raw) // compile error

	FindUser(UserId(raw)) // ok
}

这个显式转换很重要。它像是在代码里留下了一道小门:你可以从 string 走到 UserId,但你得说清楚你正在这么做。

这个东西很适合表达领域概念。

type UserId string
type OrderId string

func LoadUser(id UserId) {}
func LoadOrder(id OrderId) {}

现在 UserIdOrderId 底层都是 string,但它们不会被随手混在一起。

var userId UserId = "u_123"
var orderId OrderId = "o_456"

LoadUser(orderId) // compile error
LoadOrder(userId) // compile error

这已经有了 newtype 的味道。不过 Go 的 defined type 不是“完全封装”的包装类型,它仍然有一个明确的 underlying type,也保留了很多底层类型可用的操作。

比如:

type UserId string

func (id UserId) IsEmpty() bool {
	return id == ""
}

你可以给它挂方法,这样 UserId 就不只是一个字符串别名,而是一个带行为的小类型。

Go 这里的设计很简洁:直接给你一个简单、直接、够用的类型边界。

Rust

Rust 的 type UserId = String 不会创造边界。要创造边界,常见做法是 newtype pattern。

struct UserId(String);

这是真的新类型。

struct UserId(String);
struct OrderId(String);

fn load_user(id: UserId) {}
fn load_order(id: OrderId) {}

fn main() {
    let user_id = UserId(String::from("u_123"));
    let order_id = OrderId(String::from("o_456"));

    load_user(order_id); // compile error
    load_order(user_id); // compile error
}

UserId 里面包着一个 String,但它不是 String。这个区别非常 Rust:类型边界是真边界,编译器会认真对待。

如果你希望外部不能随便构造它,可以把字段设为私有,然后提供构造函数。

pub struct UserId(String);

impl UserId {
    pub fn new(value: String) -> Option<Self> {
        if value.starts_with("u_") {
            Some(Self(value))
        } else {
            None
        }
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

这里边界就不只是“不能把 OrderId 传给 UserId”了,还可以顺手放进校验规则。只有满足规则的字符串,才能被抬升成 UserId

Rust 的代价也很清楚:你造了新类型,就要给它补实现。

需要打印?实现 Display
需要比较?派生 PartialEqEq
需要排序、哈希、序列化?继续加。

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct UserId(String);

这正是 Rust 的风格:它让边界非常明确,但不会假装这个边界没有成本。你想要一个新类型,就要把它作为新类型认真对待。

Scala

Scala 3 的 opaque type 是本节“不透明类型”这个词的来源。

object domain:
  opaque type UserId = String

  object UserId:
    def apply(value: String): UserId = value

  extension (id: UserId)
    def value: String = id

domain 这个定义作用域内部,UserId 知道自己底层是 String。所以 apply 可以直接返回 valueid.value 也可以直接把它当成 String 取出来。

但出了这个作用域,事情就变了。

import domain.*

val id: UserId = UserId("u_123")

val raw: String = id      // compile error
val id2: UserId = "u_456" // compile error

val raw2: String = id.value // ok

这就是 opaque 的意思:底层表示没有消失,但它不暴露给外部。

它不像普通 type alias 那样完全透明:

type UserId = String

也不像 case class 那样一定要在运行时多包一层:

case class UserId(value: String)

opaque type 更像是在编译期立了一道墙:墙里面知道它是 String,墙外面只知道它是 UserId。这个设计非常适合表达领域模型里的轻量类型。

比如同样是 Int,可以分成不同语义:

object Access:
  opaque type Permissions = Int
  opaque type PermissionChoice = Int

它们底层都可以是 Int,但外部看是两个不同概念。你可以只暴露允许的操作,不把 Int 的所有能力原样放出去。

这点很关键。opaque type 不只是“让 String 看起来不像 String”。它真正有用的地方在于:你可以重新设计这个类型对外提供的 API

横向看:名字、身份和边界

三门语言放在一起看,会发现它们的差别不是语法差别,而是类型身份的处理方式不同。

Go:
  type UserId = string  // alias,同一个类型
  type UserId string    // defined type,新类型,底层是 string

Rust:
  type UserId = String  // alias,同一个类型
  struct UserId(String) // newtype,新类型,包装 String

Scala:
  type UserId = String         // alias,同一个类型
  opaque type UserId = String  // 外部不透明,内部透明

如果只是消除重复、改善表达,alias 很好。

这个类型太长了,给它起个名字。

如果要防止概念混用,就要更进一步。

这个值虽然底层是字符串,但它不是任意字符串。

这时 Go 会说:定义一个新的 defined type。
Rust 会说:包一层 newtype,然后把 API 写清楚。
Scala 3 会说:用 opaque type,让内部和外部看到不同的类型事实。

我挺喜欢这个角度,这是在看一个更本质的问题:

类型到底只是值的形状,
还是程序员给概念画出来的边界?

类型别名站在前一边。它说:这里有个已有类型,我给它一个更合适的名字。

不透明类型、新类型、defined type 站在后一边。它们说:这里有个概念,哪怕它底层和另一个东西长得一样,也不应该被随便混用。

这两种能力都重要。别名让代码可读,不透明边界让代码更难写错。

很多时候,我们真正想要的不是更复杂的类型系统,而是更准确地表达一句话:

它底层是什么,不等于它在业务里是什么。