bun/no-cast-to-brand
Disallow casts to a branded type outside the module that declares it
A branded type such as UserId says "this string has been checked". That's only true if every UserId comes from the function that does the check. Writing raw as UserId anywhere else creates one from an unchecked value, and the compiler accepts it.
The rule does nothing until you list your branded types in branded.
Configure#
Off by default. To turn it on:
[lint.rules]
"bun/no-cast-to-brand" = "error" # or "warn"Every option, set to its default. The ones that are commented out are unset by default.
[lint.rules]
"bun/no-cast-to-brand" = ["error", {
# branded = <list of objects>,
}]Examples#
These examples use this config:
[lint.rules]
"bun/no-cast-to-brand" = ["error", {
branded = [{ module = "src/ids", type = "UserId" }],
}]{ "branded": [{ "module": "src/ids", "type": "UserId" }] }1 problemimport type { UserId } from "./ids";
export function userOf(params: { id: string }) {
return load(params.id as UserIdThis cast creates a UserId from an unchecked value. Only src/ids should create one.);
}Casts to an array, tuple, or union of the type, or to an alias of it, are reported too:
{ "branded": [{ "module": "src/ids", "type": "UserId" }] }1 problemimport type { UserId } from "./ids";
const admins = ["u_1", "u_2"] as UserId[]This cast creates a UserId from an unchecked value. Only src/ids should create one.;{ "branded": [{ "module": "src/ids", "type": "UserId" }] }No problemsimport { parseUserId } from "./ids";
export function userOf(params: { id: string }) {
return load(parseUserId(params.id));
}The module that declares the type is where the cast belongs:
{ "branded": [{ "module": "src/ids", "type": "UserId" }] }No problemsexport type UserId = string & { readonly brand: unique symbol };
export function parseUserId(text: string): UserId {
if (!text.startsWith("u_")) throw new TypeError(`not a user ID: ${text}`);
return text as UserId;
}Options#
branded is a list of objects with these keys:
| Key | Default | What it does |
|---|---|---|
module | required | The end of the path to the file that declares the type, without the extension, like "src/ids" or "ids". A package name works too. |
type | required | The type's name as the module exports it. |
mint | [] | Functions in the module that create a value of the type without checking it. |
mintedIn | [] | The ends of the paths to the files that can import the mint functions, with the extension. |
With mint, importing or re-exporting one of those functions is reported in every file that isn't in mintedIn:
{ "branded": [{ "module": "src/ids", "type": "UserId", "mint": ["unsafeUserId"], "mintedIn": ["src/db.ts"] }] }1 problemimport { unsafeUserIdunsafeUserId turns any value into a UserId, and this file is not allowed to import it. } from "./ids";
const id = unsafeUserId(request.headers.get("x-user") ?? "");{ "branded": [{ "module": "src/ids", "type": "UserId", "mint": ["unsafeUserId"], "mintedIn": ["src/db.ts"] }] }No problemsimport { unsafeUserId } from "./ids";
export const toUser = (row: { id: string }) => ({ id: unsafeUserId(row.id) });