DEV Community

Cover image for Should You Use Custom ID Types or Plain Strings?
Doogal Simpson
Doogal Simpson

Posted on • Originally published at doogal.dev

Should You Use Custom ID Types or Plain Strings?

Should you use a custom ID type or just stick to strings? Sticking to raw strings is simpler and faster to write, but wrapping your IDs in a custom domain type prevents developer mistakes—like running substring or split operations on a UUID. The right choice depends on your team's preference for speed versus absolute correctness.

I have lost count of the times I have seen developers treat IDs as generic strings, only for it to backfire. Imagine debugging a production crash only to find someone ran a .split('-') on a database UUID to "extract a prefix" for a lookup. It sounds absurd, but when IDs are passed around as raw strings, they inherit all of string's behaviors. I have noticed that developers have incredibly strong opinions on this, so I want to talk about the age-old debate: primitive strings versus custom ID types.

Why is using primitive strings for database IDs considered bad practice?

I find that using raw strings for database IDs is risky because it exposes a massive API surface of string manipulation methods that make no sense for an identifier. It allows developers to treat a unique domain identifier as arbitrary text, which often leads to silent bugs and loose domain boundaries.

In my view, an ID exists for one primary reason: equality comparison. You want to know if RecordA.id === RecordB.id. You never need to pad an ID, slice it, or regex-match it in normal business logic.

Think of an ID like a physical key to a house. You do not slice a physical key in half to see if it fits a smaller lock; you either use it to open the door, or you do not. When we type an ID as a string, I feel like we are giving developers a Swiss Army knife when they only needed a key.

How do you implement a custom ID type in code?

When I implement a custom ID type, I wrap the primitive value inside a dedicated class or value object. This encapsulates the internal representation and restricts the available operations solely to creation and equality checks.

Here is a lightweight example of how I like to wrap a primitive string ID in TypeScript:

class UserId {
  constructor(private readonly value: string) {}
  equals(other: UserId): boolean {
    return this.value === other.value;
  }
  toString(): string {
    return this.value;
  }
}
Enter fullscreen mode Exit fullscreen mode

What are the trade-offs of custom ID types vs primitive strings?

From what I have seen, this trade-off is a classic battle between cognitive overhead and runtime safety. While custom ID types prevent illegal operations and accidental type mixing, they require extra boilerplate, serialization handling, and onboarding effort for new team members.

Here is how I break down the architectural and developer experience impacts of both approaches:

  • Raw Strings (The Fast Path)
    • Pros: Zero boilerplate; works out of the box with JSON serialization and ORMs; lower cognitive load.
    • Cons: Allows nonsensical operations (like id.split()); makes it easy to accidentally pass a ProductId into a function expecting a CustomerId because both are just strings.
  • Custom ID Types (The Safe Path)
    • Pros: Prevents domain errors at compile-time; restricts API surface to equality checks only; highly self-documenting.
    • Cons: Requires custom serializers for database and API boundaries; adds slight friction when onboarding new developers.

How should a team decide between custom IDs and strings?

I always advise teams to choose based on their operational culture and domain complexity. Fast-moving startups or prototyping teams usually prefer the speed of raw strings, while teams building long-lived, high-stakes systems benefit heavily from the strict correctness of custom types.

Ultimately, I do not think either approach is objectively wrong. If your team culture values shipping fast and trusts team discipline to keep things clean, strings are fine. But if you are building a system where a mixed-up ID could mean transferring money to the wrong account, I believe that compile-time safety is worth every line of boilerplate.

FAQ

Does using a custom ID type hurt database query performance?

No, it does not. Custom ID types only exist at the application layer for compile-time or runtime safety. Once the data is compiled down or serialized, it is sent to the database as a standard primitive (like a string or binary UUID), leaving query performance completely unaffected.

How do you handle JSON serialization with custom ID types?

Most modern languages and frameworks allow you to write custom serialization adapters. For instance, I configure my JSON parser to automatically serialize the custom type into its raw string value when sending payloads over the network, and deserialize it back into the class upon receipt.

Can TypeScript's 'branding' feature solve this without classes?

Yes, TypeScript allows you to use "branded types" (or nominal typing) to differentiate strings at compile time without creating runtime classes. This gives you type safety preventing you from passing a CustomerId to a ProductId parameter, while keeping the runtime value a plain string.

Top comments (0)