Skip to content

feat(coerce): add @constructive-io/coerce and re-base 12factor-env's lenient parsers on it - #1720

Merged
pyramation merged 2 commits into
mainfrom
feat/coerce-package
Aug 13, 2026
Merged

feat(coerce): add @constructive-io/coerce and re-base 12factor-env's lenient parsers on it#1720
pyramation merged 2 commits into
mainfrom
feat/coerce-package

Conversation

@pyramation

Copy link
Copy Markdown
Contributor

Summary

Every service re-types the same four coercions inline, each subtly different — asString/asInteger/requireString/iso appeared again in constructive-db#3081's gateway lane, and the same shapes already live in its webhook gateway, node runtime (site/auth/email), knative reconciler specs, and ~14 spots here. This adds the published home for them and makes 12factor-env a consumer rather than a second implementation. Design + rationale: constructive-io/constructive-planning#1644 (comment).

packages/coerce@constructive-io/coerce, zero dependencies. Two families over unknown: as* is lenient (T | null), require* throws a labelled CoerceError.

asString | asNumber | asInteger | asBoolean | asStringArray | asStringList
        | asRecord | asDate | asIsoString | asOneOf
require*  // one per as*, throws CoerceError { label }

The rules are the interesting part, and each is a bug we've shipped:

  • No guessing across types'5' is not an integer, 1 is not a string. Only asBoolean/asDate/asStringList read strings, because env vars and query strings carry nothing else.
  • A blank string is absent''/' 'null, so an empty value can never read as a supplied identifier (the security property PR #3081 depends on).
  • All-or-nothing listsasStringArray(['a', '']) is null, not ['a']: dropping bad entries is how an allowlist quietly shrinks.
  • No transport semanticsCoerceError carries the failing label, never an HTTP status, so the package stays usable off the request path. A server maps it at its own boundary: if (err instanceof CoerceError) throw new HttpError(400, err.message).
  • asIsoString passes a string through verbatim rather than round-tripping it through Date, so a caller's cursor survives byte-for-byte.

12factor-env layers on it instead of being merged into it: the package keeps envalid schemas, redaction and cross-field checks, and its three lenient parsers become env-shaped adapters where only the env convention ("unset or blank is undefined, not a default") remains local.

-const parseEnvBoolean = (val?: string) => {
-  if (val === undefined || val === '') return undefined;
-  return ['true', '1', 'yes'].includes(val.trim().toLowerCase());
-};
+const parseEnvBoolean = (val?: string): boolean | undefined =>
+  asString(val) === null ? undefined : asBoolean(val) ?? false;

boolish() collapses to asBoolean(value) ?? false. Signatures and the existing 70 tests are unchanged; the one intentional widening is that parseEnvBoolean/boolish now also accept on/t/y (and read off/f/n as false), where before anything outside true|1|yes was false. envalid-backed list()/num() in validators.ts are untouched — their EnvError messages are schema-level.

Folding coerce into 12factor-env was rejected: the name promises env vars, so payload coercion hidden there invites the next inline reinvention, and every consumer (e.g. the node gateway) would inherit envalid for four ten-line functions.

Follow-up, after this publishes: constructive-db depends on the package and deletes its inline copies. That swap is not a blocker for #3081.

Link to Devin session: https://app.devin.ai/sessions/4283bbf118594207ad8b1ef1ce856ce8
Requested by: @pyramation

@pyramation pyramation self-assigned this Aug 13, 2026
@devin-ai-integration

Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@pyramation
pyramation merged commit e83181d into main Aug 13, 2026
20 checks passed
@pyramation
pyramation deleted the feat/coerce-package branch August 13, 2026 23:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant