--- name: types description: Type design — brands for proof, parse-don't-validate at I/O boundaries, capability composition. Load when designing a new type or port. user-invocable: false --- # Type Design Types carry meaning beyond shape. A well-designed type system encodes invariants, identity, and capabilities — not just data layout. Three patterns reinforce each other: parsing produces typed values at I/O boundaries, brands carry proof on those values, capability composition decomposes them into the minimum surface each consumer needs. Examples are TypeScript. For Rust, Swift, or Python, `Read` `references/languages.md` (relative to this skill's base directory) before writing the type. --- ## Brands Brands are not a default tool. Reach for them when one of two specific problems applies. ### Problem 1: exhaustive discriminator checks are impractical Event buses, message queues, plugin registries, observer patterns — places where you can't `switch` over every variant because the set of variants isn't closed or the consumer is generic. A brand on each event/message constant carries its payload type, so dispatch and subscription become type-safe per-event without a god union. ```ts type EventName

= string & { readonly __payload: P }; const USER_CREATED = 'user.created' as EventName<{ id: UserId; email: Email }>; const ORDER_PLACED = 'order.placed' as EventName<{ orderId: OrderId; total: Cents }>; bus.dispatch(USER_CREATED, { id, email }); // payload type-checked bus.on(USER_CREATED, ({ id, email }) => { ... }); // handler typed ``` ### Problem 2: a value must prove it was parsed/constrained `UserId` distinct from `OrgId` (both `string`); `Email` distinct from raw `string`; `Cents` distinct from arbitrary `number`. The brand is proof that the value passed through the parser/factory that enforces the invariant. Downstream code never re-checks. If neither problem applies, don't brand. Convenience-driven branding adds noise without payoff. ### Pattern TypeScript has no native nominal types; a phantom marker is the workaround: ```ts type Brand = K & { readonly __brand: T }; type UserId = Brand; export const parseUserId = (s: string): UserId | null => /^[a-z0-9]{12}$/.test(s) ? (s as UserId) : null; ``` ### Smells - `as Brand` outside the parser/factory — defeats the proof - Brands constructed at many call sites instead of a single chokepoint - `string` / `number` parameters where a brand would carry meaning - Brands without a corresponding parser — the type exists but nothing can safely produce it - Re-checking the brand's invariant downstream — the brand is supposed to make that unnecessary --- ## Parse, don't validate ### Principle At every serialization boundary — every place data crosses I/O — parse untrusted input into a strongly-typed (often branded) value. After parsing, the type is proof. Consumers below the parser trust it; no re-validation. ### Where parse boundaries live Anywhere data crosses a serialization layer: - HTTP request / response bodies - User input (forms, CLI args) - Environment variables - Configuration files - Message queue payloads - IPC / RPC messages - File reads of structured formats ### Pattern The parser is the only function that produces the typed value. Failure produces a typed error or absent value. Downstream code accepts the typed value and trusts it. ```ts // validate (bad) function send(to: string) { if (!isEmail(to)) throw new Error('bad email'); // every future call site has to remember } // parse (good) function parseEmail(s: string): Email | ParseError { ... } function send(to: Email) { /* type proves it */ } ``` ### Smells - Validation logic scattered across consumer code — the type isn't carrying its weight - Re-validating after a parse — you don't trust your own types - `unknown` / `any` flowing past a boundary into application code - Branded types with no parser (impossible to safely construct) - Parser that returns the same primitive (`parseEmail(s: string): string`) — no proof carried ### Library notes TS: zod, valibot, arktype, io-ts. Match the project's existing choice; don't introduce a new parser library on a whim. --- ## Capability composition ### Principle Decompose types into many narrow interfaces. Combine at use sites; accept the minimum capability needed. Avoid monolithic interfaces and deep inheritance. ### Pattern Intersection: ```ts interface Readable { read(): Promise } interface Writable { write(value: T): Promise } interface Closable { close(): Promise } type Stream = Readable & Writable & Closable; function consume(s: Readable) { /* accepts minimum */ } ``` ### Smells - God interfaces with many methods where callers only need a few - Deep inheritance hierarchies where flat composition would do - Functions taking concrete classes instead of capability sets - Consumer requires `Stream` when it only reads — over-permissioning - Capabilities atomised past usefulness (split for splitting's sake) ### Trade-offs Right granularity: "things that change together stay together; things that vary independently split." A capability never used in isolation is fine bundled. Don't atomise to the point of confusion. --- ## How these reinforce - **Parsing produces branded values.** The parser is the construction chokepoint that enforces the brand's invariant. - **Brands can witness capabilities.** A `WriteHandle` brand on a resource proves write access; passing it is the type-level grant. - **Ports** (per the `boundaries` skill) are best specified as capability intersections — not a monolithic `UserPort` but the minimum capabilities each consumer needs. --- ## When to relax - Throwaway scripts: pass strings, validate inline, move on - Prototype phase: shape first, brands and parsers later - Foreign-system contracts you don't control: live with unsafe types at the exact seam, isolate them, parse at the next boundary you do own