· 6 min read ·

The Types Your Dynamic Code Already Carries

Source: lobsters

A recent piece on The Consensus, “Seeing types where others don’t”, touches on something that reshapes how you read code once it clicks: the line between typed and untyped is mostly a question of what the compiler is willing to enforce, not a statement about whether types exist.

Types exist in every program, regardless of whether the language acknowledges them.

The Untyped Code That Isn’t

Take a typical Discord bot command handler written in Python:

async def ban_user(guild_id, user_id, moderator_id, reason):
    await client.ban(guild_id, user_id)
    await log_action(moderator_id, "ban", user_id, reason)

Four parameters, all integers (Discord snowflake IDs), all named differently. Python doesn’t enforce anything here. You could pass user_id where guild_id is expected and Python would call the Discord API with nonsense values. The types are implicit, living in the variable names, in the documentation if you write it, in the runtime errors you’ll eventually get when you mix them up.

The code already has a type system; it’s just not enforced.

This is what “latent types” means in practice. Every variable in a dynamic program has a type, or more precisely a set of operations that make sense on it. When those operations don’t match what you pass, you get an AttributeError or a TypeError or a 400 from an external API. The type checker you skipped writing is replaced by tests, documentation, and production incidents.

Snowflakes and the Newtype Pattern

Discord’s snowflake IDs are a concrete illustration because they look like integers but behave differently from integers. You can’t add two snowflake IDs meaningfully. Comparing them numerically only makes sense if you’re extracting timestamps (snowflakes encode creation time in their high bits). Passing a user ID where a channel ID is expected is logically wrong, but both are the same primitive type under the hood.

In Rust, the conventional fix is the newtype pattern:

struct UserId(u64);
struct ChannelId(u64);
struct GuildId(u64);

fn ban_user(guild: GuildId, user: UserId, moderator: UserId, reason: &str) {
    // ...
}

The compiler now refuses code that passes a ChannelId where a UserId is expected. The type information that previously lived in variable names and documentation is now enforced by the type system. Nothing changed about the underlying data representation; the invariant moved from advisory to load-bearing.

TypeScript has a version of this called branded types:

type UserId = string & { readonly __brand: 'UserId' };
type ChannelId = string & { readonly __brand: 'ChannelId' };

function makeUserId(id: string): UserId {
    return id as UserId;
}

It’s a hack built on intersection types, but it works. You brand the type at the boundary where data enters the system, and from that point the type checker enforces the distinction everywhere. Libraries like type-fest ship a Tagged<Type, TagName> utility that formalises the pattern so you don’t have to reinvent the intersection trick each time.

Python’s answer is NewType from the typing module:

from typing import NewType

UserId = NewType('UserId', int)
ChannelId = NewType('ChannelId', int)
GuildId = NewType('GuildId', int)

async def ban_user(
    guild_id: GuildId,
    user_id: UserId,
    moderator_id: UserId,
    reason: str,
) -> None:
    ...

With mypy or pyright, this catches cross-type mistakes statically. Without a checker it’s documentation. The underlying code doesn’t change either way; the types were always there, expressed in variable names rather than syntax.

The Spectrum of Enforcement

What varies across languages isn’t whether types exist, but how far up the development pipeline enforcement happens:

  • No annotations: types exist in programmer intent and documentation only
  • Runtime checks: types are enforced when code runs (isinstance, Pydantic validators)
  • Static analysis: types are enforced at build time by a checker (mypy, pyright, TypeScript)
  • Compiler enforcement: types are part of the language grammar and code won’t compile without them (Rust, Haskell, OCaml)
  • Dependent and refinement types: types can encode invariants about values, not just structure (Coq, Lean 4, Liquid Haskell)

Moving left means discovering type errors later in the cycle, typically in tests or in production. Moving right means discovering them earlier, during editing or compilation. Neither end eliminates bugs entirely, but the cost of a type mismatch drops considerably when it’s caught before the code runs.

The observation at the heart of the original article is that developers working in dynamic languages reason about types constantly anyway; they just do it in their heads. A senior Python engineer reviewing code will immediately notice that a parameter named user_id shouldn’t receive a guild_id, even without annotations. The type information is load-bearing, held in human memory rather than a checker. The question is only whether that mental model gets encoded somewhere the toolchain can use.

Refinement Types: When Primitives Aren’t Specific Enough

Newtypes get you nominal type safety: you can distinguish a UserId from a ChannelId. But there’s a further step. Refinement types encode not just what kind of value something is, but constraints on the value itself.

A port number is a u16 in Rust, but not every u16 is a valid non-privileged port. Ports below 1024 require elevated privileges on most systems. A refinement type captures this constraint directly:

ValidPort = { p: u16 | p >= 1024 && p <= 65535 }

Liquid Haskell supports this syntax and verifies these constraints at compile time through SMT solving. Rust doesn’t have refinement types natively, but the smart constructor pattern approximates the guarantee:

#[derive(Debug, Clone, Copy)]
pub struct UserPort(u16);

impl UserPort {
    pub fn new(port: u16) -> Option<Self> {
        if port >= 1024 {
            Some(UserPort(port))
        } else {
            None
        }
    }

    pub fn get(self) -> u16 {
        self.0
    }
}

Any function accepting a UserPort is documented by the type system: it will never receive a privileged port. The invariant is enforced at construction time, at the single boundary where external input enters the system. Everything inside that boundary is clean.

Pydantic does something similar in Python, enforced at runtime:

from pydantic import BaseModel, Field

class ServerConfig(BaseModel):
    port: int = Field(ge=1024, le=65535)
    host: str

Same concept, different enforcement point. The type says something about the value, not just about its primitive representation. The constraint was always there in the domain model; Pydantic gives it a place to live in the code.

Structural Subtyping: The Type the Duck-Typist Already Had

One place the latent-types framing matters especially: protocols and interfaces. Python’s structural subtyping via Protocol (PEP 544, shipped in Python 3.8) lets you describe what a type can do without requiring inheritance from a base class:

from typing import Protocol

class Closeable(Protocol):
    def close(self) -> None: ...

def cleanup(resource: Closeable) -> None:
    resource.close()

Any object with a close() method satisfies Closeable. No declaration, no inheritance required. The structural type relationship already existed in the duck-typing convention Python programmers had used for decades; PEP 544 gave it a name the type checker could understand.

Go took this route from the beginning. An interface in Go is satisfied implicitly: if a type has the right methods, it implements the interface, no explicit declaration needed. The relationship existed before the interface was defined; the interface just names something that was already there in the method set.

This is the pattern the “seeing types” framing points toward consistently. The structural type relationship in duck-typed Python code isn’t absent; it’s unnamed. Naming it with Protocol doesn’t create new information, it surfaces existing information and gives the toolchain somewhere to check it.

What Changes When You Start Seeing It

The practical value of this framing isn’t philosophical. It changes how you review code, how you design APIs, and how you debug.

When you review a pull request in Python, the question isn’t just “does this function work?” but “what type discipline does this parameter require, and is that enforced anywhere or just assumed?” When you design a Discord bot command, you consider whether a raw int is the right type for a snowflake ID, or whether a thin wrapper prevents mixing ID types across call sites. When you trace an AttributeError at 2am, you’re reading a type error the compiler didn’t catch on your behalf.

Libraries like Zod for TypeScript and Pydantic for Python have grown popular because they let you name and enforce type structure that was always present. They didn’t invent the types; they gave existing type constraints somewhere to live in the code itself rather than in documentation and programmer memory. The adoption of these libraries in codebases that were explicitly “untyped” is evidence that the type information was there all along and developers were already tracking it manually.

The developers who see types where others don’t are reading the same programs as everyone else. They notice the implicit contracts, the invariants embedded in variable names, the constraints that live in comments until they break and show up in stack traces. Writing types is, in large part, the work of making explicit what the people who wrote the code already knew.

Was this interesting?