string-flags
v0.1.4
Published
Developer-friendly, production-ready, human-readable alternative to binary flags.
Maintainers
Readme
string-flags
Developer-friendly, production-ready, human-readable alternative to binary flags.

Store a set of flags in a single string, keep it in a strict, self-healing protocol form, and get full TypeScript autocomplete on every legal subset.
import { addStringFlag, toggleStringFlag, hasStringFlag, type FlagsString } from "string-flags";
type State = "idle" | "busy" | "error" | "blocked";
let flags: FlagsString<State> = "";
flags = addStringFlag<State>(flags, "busy"); // "busy"
flags = addStringFlag<State>(flags, "blocked"); // "blocked,busy"
flags = toggleStringFlag<State>(flags, "busy"); // "blocked"
flags = toggleStringFlag<State>(flags, "busy"); // "blocked,busy"
hasStringFlag<State>(flags, "busy"); // trueType-safe and autocompletable
FlagsString<U> is not a generic string — it's a type-safe union of every legal, alphabetically ordered combination of your flags, with full type checking and autocomplete. TypeScript enforces the protocol at compile time, so non-compliant values simply do not type-check.
type State = "idle" | "busy" | "error" | "blocked";
const a: FlagsString<State> = ""; // 🟢 empty set
const a1: FlagsString<State> = "busy"; // 🟢 single flag
const a2: FlagsString<State> = "blocked,busy"; // 🟢 alphabetical
const a3: FlagsString<State> = "blocked,busy,error,idle"; // 🟢 full set
const b: FlagsString<State> = "busy,blocked"; // 🔴 wrong alphabetical order
const c: FlagsString<State> = "busy,busy"; // 🔴 duplicate
const d: FlagsString<State> = "paused"; // 🔴 unknown flag
const e: FlagsString<State> = "blocked,"; // 🔴 trailing commaAutocomplete lists every legal subset — the empty string, then singletons, pairs, triples, up to the full set. Typos do not compile. When a user edits a string by hand and puts the flags out of alphabetical order, the runtime normalizes the value and emits a warning that cites the reason (see The protocol below).
Example
import { defineStringFlags, type FlagsString } from "string-flags";
type Role = "admin" | "editor" | "viewer" | "suspended";
const roles = defineStringFlags<Role>(["admin", "editor", "viewer", "suspended"]);
class User {
constructor(public roles: FlagsString<Role> = "") {}
grant(role: Role) { this.roles = roles.addFlag(this.roles, role); }
revoke(role: Role) { this.roles = roles.removeFlag(this.roles, role); }
isActive() { return !roles.hasFlag(this.roles, "suspended"); }
canEdit() { return this.isActive()
&& roles.hasAnyFlag(this.roles, ["admin", "editor"]); }
}
// Validate anything you didn't type yourself.
const fromDb: unknown = "editor,viewer";
roles.assertFlagsString(fromDb, "invalid roles");
const user = new User(fromDb); // fromDb is now typed as FlagsString<Role>
user.grant("admin"); // "admin,editor,viewer"
user.canEdit(); // true
user.grant("suspended");
user.canEdit(); // false
user.grant("moderator"); // 🔴 TS error — not a RoleInstall
yarn add string-flags
# or
npm install string-flagsRequires Node 18+ and TypeScript 5+.
Why not bitmasks?
Bitmasks are great for computers. They are tricky for humans — even developers familiar with binary numbers:
- Opaque. You cannot understand what
5means without looking up the mask definition. - Not how we think. Even when you know the flags, you think in names (
"busy","blocked"), not in bits. - Hard for humans to edit. A database row that says
blocked,idleis obvious to a non-technical user.5is not. - Import everywhere. Anywhere you want to check a bit, you have to import the enum of masks.
- Awkward syntax.
|,&,^,~,&=,<<,>>— correct, but uncommon in everyday JavaScript. - No help from the compiler on combinations.
BUSY | IDLEis just anumber; TypeScript cannot tell you which combinations are meaningful.
string-flags addresses all of these:
- Readable in logs, URLs, database rows, and JSON payloads.
- Type-safe for single flags and for combinations.
FlagsString<U>is a finite union, not a string. - Alphabetical autocomplete on every legal subset — the IDE teaches the API as you type.
- Safe to diff. Flags are always alphabetically ordered, so a given set has exactly one string form — you never see
"busy,idle"in one place and"idle,busy"in another. - Safe to extend. Adding a flag does not reindex old data; old strings keep their meaning.
defineStringFlags
You can also define a schema with an explicit list of allowed flags and use it for strict, type-checked operations against that list.
import { defineStringFlags, type FlagsString } from "string-flags";
type State = "idle" | "busy" | "error" | "blocked";
const state = defineStringFlags<State>(["idle", "busy", "error", "blocked"]);
let s: FlagsString<State> = "";
s = state.addFlag(s, "busy"); // "busy"
s = state.addFlag(s, "blocked"); // "blocked,busy"
s = state.toggleFlag(s, "busy"); // "blocked"
state.hasFlag(s, "blocked"); // true
state.hasAllFlags(s, ["blocked"]); // true
state.getFlags(s); // ["blocked"]
state.isFlag("busy"); // true
state.isFlagsString("busy,idle"); // true
state.isFlagsString("idle,busy"); // false — wrong alphabetical orderExhaustive schemas with Record<U, true>
The Record<U, true> form is useful when you want the compiler to force the definition to stay in sync with the union.
Consider the array form over time:
type State = "idle" | "busy" | "error" | "blocked";
const state = defineStringFlags<State>(["idle", "busy", "error", "blocked"]);
// 🟢 compilesLater a teammate adds a new state:
type State = "idle" | "busy" | "error" | "blocked" | "paused";
const state = defineStringFlags<State>(["idle", "busy", "error", "blocked"]);
// 🔴 silently missing "paused" — TypeScript cannot catch this.The Record<U, true> form does catch it:
const state = defineStringFlags<State>({
idle: true,
busy: true,
error: true,
blocked: true,
// 🔴 TS error: Property 'paused' is missing in type '{...}'
// but required in type 'Record<State, true>'.
});The protocol
string-flags is based on a simple, strict protocol aimed at no undefined behaviour and resilience.
The rules:
- Flags are joined with a single comma
,. No whitespace. - The list is always alphabetical.
- No duplicates.
- Flag names match
/^[a-zA-Z0-9]+$/— no special characters. - The empty string
""is valid and means no flags.
// 🟢 valid
""
"busy"
"blocked,busy"
"blocked,busy,error,idle"
// 🔴 invalid
"busy,blocked" // wrong alphabetical order
"busy, blocked" // whitespace
"busy,busy" // duplicate
"blocked," // trailing comma
"my-flag" // disallowed characterThe library is self-fixing. Flag strings leak into places humans can edit — config files, database rows, URL parameters. When something comes back out of order or with duplicates, the library normalizes it and emits a console.warn that cites the specific reason. The returned value is always in protocol form.
state.getFlags("busy,blocked");
// warn: input "busy,blocked" does not follow the protocol (not in alphabetical order);
// normalized to "blocked,busy"
// returns ["blocked", "busy"]
state.getFlags("busy,busy");
// warn: input "busy,busy" does not follow the protocol (contains duplicates);
// normalized to "busy"
state.getFlags("busy,busy,blocked");
// warn: input "busy,busy,blocked" does not follow the protocol
// (not in alphabetical order and contains duplicates);
// normalized to "blocked,busy"The library is strict where self-fixing would be unsafe. If a schema-based operation receives a flag it does not know about, it has no safe move:
- Dropping the unknown flag silently would lose information — the caller meant it to be there.
- Keeping it would mean returning a
FlagsString<State>that contains a non-Statemember. That is a lie to TypeScript, and it propagates undefined behaviour downstream.
So the library refuses to guess and throws. The same applies to invalid characters and non-string input.
state.getFlags("blocked,paused");
// throws: unknown flag "paused"
state.getFlags("blocked,my-flag");
// throws: flag "my-flag" contains disallowed characters.Strict mode
Non-strict mode (the default) is for flag strings that may have been edited by a human without an IDE or other type-checking assistance — a database row, a config file, a URL parameter. The library self-fixes the recoverable mistakes and emits a warning.
Strict mode is for places where flag strings only ever come from type-checked code. There a protocol violation is always a bug, and you want it to fail loudly.
Either way, truly ambiguous input (unknown flag names, invalid characters, wrong type) always throws — strict mode only changes how recoverable problems are surfaced.
| Situation | Non-strict (default) | Strict | | -------------------------------------------- | -------------------- | ------- | | Wrong alphabetical order or duplicates (recoverable) | warn + normalize | throw | | Unknown flag name (unrecoverable) | throw | throw | | Invalid characters, non-string input | throw | throw |
const loose = defineStringFlags<State>(["idle", "busy", "error", "blocked"]);
const strict = defineStringFlags<State>(["idle", "busy", "error", "blocked"], { strict: true });
loose.getFlags("busy,blocked");
// warn: ... (not in alphabetical order); normalized to "blocked,busy"
// returns ["blocked", "busy"]
strict.getFlags("busy,blocked");
// throws the same messageAssertions and type guards
state.isFlagsString(value); // type guard, always strict
state.assertFlagsString(value, "bad input"); // throws on protocol violation, always strict
state.assertFlag(value, new BadRequestError("...")); // returns UassertFlagsString is unconditionally strict — the whole point of an assertion is a hard boundary. To normalize input, call getFlags (or the standalone parseStringFlags).
Every assert* method accepts either a string or an Error instance. Custom Error subclasses pass through unchanged.
API reference
Types
type FlagsString<U extends string>
type StringFlagsOptions = { strict?: boolean }
type ErrorInput = string | ErrordefineStringFlags(input, options?)
Creates a StringFlags<U> schema from either an array or a Record<U, true>.
defineStringFlags<State>(["idle", "busy", "error", "blocked"]);
defineStringFlags<State>({ idle: true, busy: true, error: true, blocked: true });
defineStringFlags<State>(["idle", "busy"], { strict: true });
defineStringFlags<State>(["idle", "idle"]); // throws: duplicate flag "idle"
defineStringFlags<State>(["my-flag" as State]); // throws: disallowed charactersSchema methods
All methods on a StringFlags<U> instance.
toFlagsString(input: readonly U[])
Build a protocol-compliant string from an array.
state.toFlagsString(["busy", "blocked"]); // "blocked,busy"
state.toFlagsString(["busy", "busy", "idle"]); // "busy,idle" — deduped
state.toFlagsString([]); // ""
state.toFlagsString(["paused" as State]); // throws: unknown flag "paused"getFlags(input: FlagsString<U>)
Parse a flags string into an array. Normalizes in non-strict mode.
state.getFlags("blocked,busy"); // ["blocked", "busy"]
state.getFlags(""); // []
state.getFlags("busy,blocked");
// non-strict: warns (not in alphabetical order), returns ["blocked", "busy"]
// strict: throws (not in alphabetical order)
state.getFlags("blocked,paused"); // throws: unknown flaghasFlag(input, flag)
state.hasFlag("blocked,busy", "busy"); // true
state.hasFlag("blocked,busy", "idle"); // false
state.hasFlag("blocked", "paused"); // TS error: "paused" is not in State
state.hasFlag("busy,blocked", "busy"); // non-strict: warns (not in alphabetical order) + returns truehasAllFlags(input, required) / hasAnyFlag(input, candidates)
state.hasAllFlags("blocked,busy,idle", ["busy", "idle"]); // true
state.hasAllFlags("blocked,busy", ["busy", "idle"]); // false
state.hasAnyFlag("blocked", ["idle", "blocked"]); // true
state.hasAnyFlag("blocked", ["idle", "busy"]); // falseaddFlag(input, flag)
state.addFlag("", "idle"); // "idle"
state.addFlag("blocked", "busy"); // "blocked,busy"
state.addFlag("blocked,busy", "busy"); // "blocked,busy" — idempotent
state.addFlag("", "paused"); // TS error: "paused" is not in StateremoveFlag(input, flag)
state.removeFlag("blocked,busy", "blocked"); // "busy"
state.removeFlag("blocked", "busy"); // "blocked" — no-op
state.removeFlag("idle", "idle"); // "" — back to emptytoggleFlag(input, flag)
state.toggleFlag("", "idle"); // "idle"
state.toggleFlag("idle", "idle"); // ""
state.toggleFlag("blocked", "busy"); // "blocked,busy"
state.toggleFlag("blocked,busy", "busy"); // "blocked"isFlag(value) / isFlagsString(value)
Type guards. Both are strict predicates — they answer "is this exactly a valid value?" without normalizing.
state.isFlag("busy"); // true
state.isFlag("paused"); // false
state.isFlagsString("blocked,busy"); // true
state.isFlagsString("busy,blocked"); // false — wrong alphabetical order
state.isFlagsString("busy,busy"); // false — duplicate
state.isFlagsString("paused"); // false — unknown flagassertFlag(value, err) / assertFlagsString(value, err)
state.assertFlag("busy", "bad"); // returns "busy"
state.assertFlag("paused", "bad"); // throws "bad"
state.assertFlagsString("blocked,busy", "bad"); // passes (and narrows the type)
state.assertFlagsString("busy,blocked", "bad"); // throws "bad" — wrong alphabetical order, always strict
state.assertFlagsString("unknown", "bad"); // throws "bad"Standalone helpers
Same semantics as the schema methods, but with no registered list — unknown flag detection is delegated to TypeScript. All accept an optional { strict?: boolean } second argument.
toStringFlags(flags)
toStringFlags<State>(["busy", "blocked"]); // "blocked,busy"
toStringFlags<State>(["busy", "busy"]); // "busy" — deduped
toStringFlags<State>([]); // ""
toStringFlags(["bad;"]); // throws: disallowed charactersparseStringFlags(input, options?)
parseStringFlags<State>("blocked,busy"); // ["blocked", "busy"]
parseStringFlags<State>(""); // []
parseStringFlags<State>("busy,blocked");
// non-strict: warns (not in alphabetical order), returns ["blocked", "busy"]
// strict: throws (not in alphabetical order)hasStringFlag(input, flag, options?)
hasStringFlag<State>("blocked,busy", "busy"); // true
hasStringFlag<State>("blocked,busy", "idle"); // false
hasStringFlag<State>("busy,blocked", "busy"); // non-strict: warns (not in alphabetical order) + trueaddStringFlag(input, flag, options?)
addStringFlag<State>("", "idle"); // "idle"
addStringFlag<State>("blocked", "busy"); // "blocked,busy"
addStringFlag<State>("blocked,busy", "busy"); // "blocked,busy"removeStringFlag(input, flag, options?)
removeStringFlag<State>("blocked,busy", "blocked"); // "busy"
removeStringFlag<State>("blocked", "busy"); // "blocked" — no-optoggleStringFlag(input, flag, options?)
toggleStringFlag<State>("", "idle"); // "idle"
toggleStringFlag<State>("idle", "idle"); // ""
toggleStringFlag<State>("idle,busy", "idle"); // non-strict: warns (not in alphabetical order) + "busy"Constraints
- Flag names match
/^[a-zA-Z0-9]+$/. - Flag names are at most 64 characters.
- Schemas have at most 10 flags (keeps the compile-time power-set within TypeScript's instantiation limits).
Contributing
The repo uses Yarn 4 via Corepack with the node-modules linker. Enable Corepack once (corepack enable) and the right Yarn version is picked automatically from packageManager in package.json.
corepack enable
yarn install
yarn test
yarn typecheck
yarn buildLicense
MIT © Adam Pietrasiak
