---
name: craft
description:
  "Engineering taste and coding standards for TypeScript/Effect-heavy codebases. Use when reviewing
  or writing code where subjective standards matter: types at seams, helpers and abstractions,
  configuration, errors, tests and fakes, naming, comments, module boundaries, or docs/ADRs.
  Stack-agnostic taste layer only for non-TypeScript code."
---

# Craft

The taste layer for code: what good TypeScript looks like when the repo does not already decide.
Specialist skills (`effect`, `cloudflare`, `tanstack-start`, …) own framework mechanics; this skill
owns the judgment calls between them.

## Operating rule

Read the repo first. A current, intentional project convention wins over these defaults. When local
code is inconsistent and it is not a deliberate migration, apply the defaults below to new code and
name the inconsistency as a convention question in your report rather than copying either side.

When defaults pull against each other, rank correctness and debuggability first, then a clear domain
model and runtime boundaries, then type safety, then local convention, then the smallest direct
implementation.

Separate what you find:

- **Bug or risk:** correctness, type safety, runtime, security, data loss.
- **Standards mismatch:** one of the defaults below, with its concrete cost.
- **Preference question:** a real tradeoff the repo does not decide. Offer the options.

## Defaults

Each default names the failure it prevents. The examples are the shape to reach for, not templates.

### 1. Seams take the real type

Type a dependency as what production actually passes: the platform binding (`Env["BUCKET"]`,
`R2Bucket`), the vendor SDK client (`S3Client`, `Stripe`), or your own service's full interface.
Never hand-write a `Pick<…>`, a role-shaped interface, or a local copy of a vendor type so a fake
compiles. Such a type restricts nothing at runtime, drifts from the real one, and makes the next
feature edit a type before it can call a method. Fakes adapt instead (see default 12).

```ts
// Before: the production signature bends to fit the test
interface BlobStore { put: Pick<R2Bucket, "put">["put"] }
export const makeUploader = (store: BlobStore) => …

// After
export const makeUploader = (bucket: R2Bucket) => …
```

### 2. Take the right fix, not the short one

When the correct fix touches many call sites, make it. Never narrow a type, cast, add a wrapper, or
keep a fallback to shrink a diff. A report that says "narrowed", "cast", or "kept the old path for
now" is a review flag.

### 3. Parse at boundaries; no casts

Decode untrusted input (HTTP, storage, queue, env, JSON) with a schema at the seam that receives it,
and keep typed or branded values inside. `as unknown as T`, `as never`, and `JSON.parse(x) as T` are
trust casts; replace them with a decode or the real type.

```ts
// Before
const user = JSON.parse(body) as User;
// After
const user = yield * Schema.decodeUnknown(User)(JSON.parse(body));
```

### 4. Derive from the owner; do not redefine

Use the vendor SDK's and platform's types instead of restating them, and derive literal unions,
DTOs, config keys, and event names from the canonical schema or spec. One source of truth; every
other mention derives from it.

```ts
// Before
type WebhookEvent = "email.sent" | "email.bounced";
// After
type WebhookEvent = typeof WebhookEventType.Type;
```

### 5. Inline first; three copies earn a shared primitive

Write it inline. When three or more sibling copies of the same logic exist, grep for all of them and
extract one shared framework-level primitive (a configured client, a Layer, a schema), never a thin
function helper that only renames a call.

```ts
// Before: a helper that owns nothing
const withDotEnvFallback = (key: string, fallback: string) => process.env[key] ?? fallback;

// After, one call site: inline
const url = yield * Config.String("API_URL");

// After, three copies of the same pinned fetch: one shared primitive
export const pinnedClient = (dest: Destination) =>
  HttpClient.make((request, url, signal) => openPinned(dest, request, url, signal));
```

### 6. Keep framework primitives visible

Do not hide `Effect`, `Layer`, `Context.Service`, `Schema`, or router and client APIs behind
project-local facades, and do not add wrapper services to shorten imports. Framework code should
look like the framework's docs.

### 7. Configuration is required

A config value the code needs is required, and a missing one fails at boot. Local and test behavior
is a separate layer the host entry chooses, not an optional value with a fallback branch.

```ts
// Before: research silently turns off
const key = yield * Config.option(Config.Redacted("EXA_API_KEY"));
if (Option.isNone(key)) return [];

// After
const key = yield * Config.Redacted("EXA_API_KEY");
export const layer = Layer.effect(Research.Service, make);
export const layerLocal = Layer.succeed(Research.Service, Research.Service.of(emulator));
```

### 8. Fail loudly

Unexpected input and broken invariants fail with typed context. No `?? default` for a value that
should be required, no empty-array or empty-string fallbacks on parse failure, no catch-all that
returns something safe-looking.

### 9. Break internal interfaces directly

For internal and unreleased code, rename, change shapes, and delete old paths in the same change,
and let TypeScript find the callers. No aliases, shims, dual reads, tombstones, or legacy
re-exports. Keep compatibility only for a named external consumer, durable data, or a rolling
deploy, with its removal condition.

### 10. Errors carry domain data

A typed error carries the data that failed (the id, the value, the upstream cause), not a label
describing where it happened. `Effect.fn` span names already record the operation.

```ts
class UserNotFound extends Schema.TaggedError<UserNotFound>()("UserNotFound", { userId: UserId }) {}

const load = Effect.fn("Users.load")(function* (id: UserId) {
  return yield* query(id).pipe(Effect.mapError((cause) => new PersistenceError({ cause })));
});
```

### 11. Contract values need producers

Do not declare an event, enum member, status, or route before code emits or serves it. A declared
value nothing produces is a promise the system does not keep. Add it with its producer.

### 12. Test at the real boundary; fakes implement the real type

Prove behavior through the smallest real boundary that matches production: PGlite or a local
database, workerd, a deployed resource. Fake only true third parties, and then implement the full
real type, with every member a test does not script throwing a named error. Prefer a real client
over a dead transport to a hand-built object. Never write a test that compares a value with its own
source or cannot fail, and delete tests that pin a shape a breaking change removed.

```ts
const unscripted = (member: string) => (): never => {
  throw new Error(`${member} is not scripted`);
};
const bucket = (members: Partial<R2Bucket> = {}): R2Bucket => ({
  head: unscripted("head"),
  get: unscripted("get"),
  put: unscripted("put"),
  delete: unscripted("delete"),
  list: unscripted("list"),
  createMultipartUpload: unscripted("createMultipartUpload"),
  resumeMultipartUpload: unscripted("resumeMultipartUpload"),
  ...members,
});
```

### 13. Platform primitives before custom infrastructure

Use the platform's queue, scheduler, cache, and storage primitives before building your own. Custom
infrastructure needs a concrete limit of the primitive that it removes.

### 14. Names say intent

- Verbs name lifecycle intent: `create` (durable state now), `submit` (create and enqueue), `plan`,
  `process`, `complete`, `publish`, `approve`. Treat `run`, `handle`, `sync`, `update` as prompts to
  check the call stack, not automatic findings.
- Constants name the policy: `MAX_ATTEMPTS`, not `SINGLE_ATTEMPT` or `LIMIT`.
- No stutter: `Principal.make`, not `Principal.Principal`.
- Drop `new`, `legacy`, `v2`, `staged` once one path remains. No `utils.ts` or `helpers.ts`; name
  the file for what it owns.
- A rename must make a real call stack more truthful, and it updates every consumer with no alias.
  When naming is a taste call, offer two or three options.

### 15. Organize by capability, in namespace modules

A capability is one module exporting its types, schemas, service, layers, and operations, imported
as a namespace (`Billing.Service`, `Billing.layer`, `Billing.charge`) through a self-export or
`import * as`, not a TypeScript `namespace` declaration. Keep domain decisions in pure functions
beside it. Split a module when parts change for different reasons, not by file length.

### 16. Comments say why

A comment states a constraint the code cannot show: why this order, why this key, which external
limit applies. Never restate the code, and never put counts, lists, or history in a comment; name
the owning symbol instead. JSDoc goes on published packages; elsewhere match the nearest sibling.

```ts
// Same key across retries so a timeout cannot create a second charge.
const idempotencyKey = `send:${message.id}`;
```

## References

| Situation                                                             | Read                                                 |
| --------------------------------------------------------------------- | ---------------------------------------------------- |
| Effect module, service, schema, error, and layer shape                | [typescript-effect](references/typescript-effect.md) |
| Module depth, deletion test, dependency categories, adapters          | [modules](references/modules.md)                     |
| Test strategy, real boundaries, fakes                                 | [testing](references/testing.md)                     |
| Terms this codebase takes a position on (failure split, `_tag`, core) | [vocabulary](references/vocabulary.md)               |
| Naming verbs, call stacks, files, constants                           | [naming](references/naming.md)                       |
| After a change: proving the real artifact, sequencing a sweep         | [prove it works](references/prove-it-works.md)       |
| `.scratchpad`, durable docs, ADRs, prose versus code                  | [docs and ADRs](references/docs-and-adrs.md)         |
| Reviewing a diff: finding bar, severity, lenses                       | [review lens](references/review-lens.md)             |
| Turning a repeated correction into an oxlint rule                     | [lint rules](references/lint-rules.md)               |

## Stack defaults

When the repo does not decide: Effect-first TypeScript (`effect`), Effect v4 `effect/unstable/cli`
for CLIs (`building-clis`), TanStack Start for new web apps (`tanstack-start`), the project's cloud
platform skill for infrastructure (`cloudflare`, or the AWS guidance the project uses), existing
design-system idioms for React, and wide structured events for logging (`logging-best-practices`).
