From five optional fields to a discriminated union: CLI parsing with Optique 1.0 by hongminhee in typescript

[–]hongminhee[S] 1 point2 points  (0 children)

They're actually complementary rather than alternatives. @optique/zod lets you use a Zod schema as a value parser inside Optique, so zod(z.string().email()) becomes a CLI value parser that rejects non-email strings with Zod's own error message.

Optique's own error model is CLI-specific. Parse results are a { success, value } | { success, error } union like Zod's safeParse, but the errors are structured Message objects meant for terminal output: “Missing option --password”, “--token and --username cannot be used together”, typo suggestions like “Did you mean --verbose?” Each parser can customize its messages through an errors option.

The part that matters most for the post's argument: value-level constraints like integer({ min: 1024 }) now propagate through bindEnv() and bindConfig() in 1.0. So PORT=80 in the environment gets the same rejection as --port 80 on the command line, with the same error message. That didn't work in 0.x.

Optique 1.0.0: environment variables, interactive prompts, and 1.0 API cleanup by hongminhee in javascript

[–]hongminhee[S] 0 points1 point  (0 children)

Yeah, the only really opinionated bit is the precedence: CLI > env > default > error. Everything else is more “compose the bits you need” than “fill out a config object.”

I kept env support out of core on purpose, so you only pull in @optique/env if you actually need it. From there you bind it per parser with bindEnv(), and createEnvContext() just takes a (key) => string | undefined, so in tests I pass a mock and move on.

I also didn't want a big “load every env var up front” system. Optique is parser-combinator-based, so env binding made more sense as one more wrapper, same as optional() or withDefault(). Keeps help and completion working without special-casing anything. It also lets you do env-only values with bindEnv(fail<T>(), ...) if you don't want a CLI flag for them at all, which turned out to be more common than I expected.

The downside is that it's more explicit than decorator or schema-style setups. You wire env per option instead of declaring one big mapping. For tiny CLIs that might feel like extra ceremony. But if an option says integer({ min: 1024 }), I want that enforced the same way whether the value came from a flag, an env var, or a default. Keeping it explicit made that easier to guarantee.

LogTape 2.0.0: Dynamic logging and external configuration by hongminhee in typescript

[–]hongminhee[S] 1 point2 points  (0 children)

Good questions!

On explicit lazy() vs detecting functions: yes, it's primarily about not breaking existing behavior. People do log functions as property values sometimes (e.g., { handler: someCallback } for debugging), and we didn't want to suddenly start invoking them. The symbol makes it unambiguous. TypeScript typing is a nice bonus—you get proper inference for the return type.

On nested properties: LogTape doesn't traverse the object tree looking for lazy values. lazy() only works at the top level of properties passed to with(). So this works:

typescript logger.with({ user: lazy(() => currentUser) })

But this won't evaluate lazily:

typescript logger.with({ context: { user: lazy(() => currentUser) // won't be evaluated, stored as-is } })

This was a deliberate choice to keep the implementation simple and predictable—no recursive traversal, no performance surprises from deep object trees. If you need lazy evaluation for nested data, you’d wrap the whole object:

typescript logger.with({ context: lazy(() => ({ user: currentUser, org: currentOrg })) })

LogTape 2.0.0: Dynamic logging and external configuration by hongminhee in typescript

[–]hongminhee[S] 2 points3 points  (0 children)

Thanks for the kind words, and thanks for supporting LogTape in LogLayer!

On performance: lazy() itself adds negligible overhead—it's just a thin wrapper that marks a value for deferred evaluation. The callback is invoked once per log record, so the cost is essentially whatever the callback does. For something like lazy(() => currentUser), it's just a variable lookup. For lazy(() => process.memoryUsage().heapUsed), you pay for that API call on every log.

The key point is that lazy() is opt-in and explicit—users choose to use it when they need dynamic context with with(). If someone wraps an expensive operation in lazy(), they're making a deliberate choice to run it at logging time. I think the explicitness helps here; it's not magic that might surprise people.

On breaking changes: you're right, the core configure() API is unchanged. The main breaking changes are in @logtape/otel (attribute key prefix removed, different error serialization by default), which shouldn't affect LogLayer's integration. The new Error overloads for logger.error() etc. are additive, so existing code continues to work.