- Home
- Categories
- Uncategorized
- Somebody told poor Aspen that his name is a slang term for litter box.
Somebody told poor Aspen that his name is a slang term for litter box.
-
Somebody told poor Aspen that his name is a slang term for litter box.
https://cdhs.net/meet-the-pets/
#sheltercat #adoptdontshop #cats -
undefined oblomov@sociale.network shared this topic on
Gli ultimi otto messaggi ricevuti dalla Federazione
-
@photex interesting!
-
Wrote about designing type-safe sync/async mode support in TypeScript. Making object({ sync: syncParser, async: asyncParser }) automatically infer as async turned out to be trickier than expected.
https://hackers.pub/@hongminhee/2026/typescript-sync-async-type-safety
-
@Paradox KDEConnect FTW
-
#anno2026 0201 #Pensiero #mattutino
Come fate a vivere nei palazzi dove c'è sempre qualcuno che a QUALSIASI ora deve fare un qualche tipo di rumore?
È previsto nel contratto del buon condòmino? 🤔
-
I recently added sync/async mode support to Optique, a type-safe CLI parser
What is Optique?
for TypeScript. It turned out to be one of the trickier features I've
implemented—the object() combinator alone needed to compute a combined mode
from all its child parsers, and TypeScript's inference kept hitting edge cases.Optique is a type-safe, combinatorial CLI parser for TypeScript, inspired by
Haskell's optparse-applicative. Instead of decorators or builder patterns,
you compose small parsers into larger ones using combinators, and TypeScript
infers the result types.Here's a quick taste:
import { object } from "@optique/core/constructs"; import { argument, option } from "@optique/core/primitives"; import { string, integer } from "@optique/core/valueparser"; import { run } from "@optique/run"; const cli = object({ name: argument(string()), count: option("-n", "--count", integer()), }); // TypeScript infers: { name: string; count: number | undefined } const result = run(cli); // sync by defaultThe type inference works through arbitrarily deep compositions—in most cases,
How it started
you don't need explicit type annotations.Lucas Garron (@lgarron@mastodon.social) opened an issue requesting
// Lucas's example: fetching Git branches and tags in parallel const [branches, tags] = await Promise.all([ $`git for-each-ref --format='%(refname:short)' refs/heads/`.text(), $`git for-each-ref --format='%(refname:short)' refs/tags/`.text(), ]);
async support for shell completions. He wanted to provide
<kbd>Tab</kbd>-completion suggestions by running shell commands like
git for-each-ref to list branches and tags.At first, I didn't like the idea. Optique's entire API was synchronous, which
made it simpler to reason about and avoided the “async infection” problem where
one async function forces everything upstream to become async. I argued that
shell completion should be near-instantaneous, and if you need async data, you
should cache it at startup.But Lucas pushed back. The filesystem is a database, and many useful
What I needed to solve
completions inherently require async work—Git refs change constantly, and
pre-caching everything at startup doesn't scale for large repos. Fair point.So, how do you support both sync and async execution modes in a composable
parser library while maintaining type safety?The key requirements were:
parse() returns T or Promise<T> complete() returns T or Promise<T> suggest() returns Iterable<T> or AsyncIterable<T> When combining parsers, if any parser is async, the combined result
must be async Existing sync code should continue to work unchangedThe fourth requirement is the tricky one. Consider this:
const syncParser = flag("--verbose"); const asyncParser = option("--branch", asyncValueParser); // What's the type of this? const combined = object({ verbose: syncParser, branch: asyncParser });The combined parser should be async because one of its fields is async.
Five design options
This means we need type-level logic to compute the combined mode.I explored five different approaches, each with its own trade-offs.
Option A: conditional types with mode parameterAdd a mode type parameter to Parser and use conditional types:
type Mode = "sync" | "async"; type ModeValue<M extends Mode, T> = M extends "async" ? Promise<T> : T; interface Parser<M extends Mode, TValue, TState> { parse(context: ParserContext<TState>): ModeValue<M, ParserResult<TState>>; // ... }The challenge is computing combined modes:
type CombineModes<T extends Record<string, Parser<any, any, any>>> = T[keyof T] extends Parser<infer M, any, any> ? M extends "async" ? "async" : "sync" : never; Option B: mode parameter with default valueA variant of Option A, but place the mode parameter first with a default
interface Parser<M extends Mode = "sync", TValue, TState> { readonly $mode: M; // ... }
of "sync":The default value maintains backward compatibility—existing user code keeps
Option C: separate interfaces
working without changes.Define completely separate Parser and AsyncParser interfaces with
interface Parser<TValue, TState> { /* sync methods */ } interface AsyncParser<TValue, TState> { /* async methods */ } function toAsync<T, S>(parser: Parser<T, S>): AsyncParser<T, S>;
explicit conversion:Simpler to understand, but requires code duplication and explicit conversions.
Option D: union return types for suggest() onlyThe minimal approach. Only allow suggest() to be async:
interface Parser<TValue, TState> { parse(context: ParserContext<TState>): ParserResult<TState>; // always sync suggest(context: ParserContext<TState>, prefix: string): Iterable<Suggestion> | AsyncIterable<Suggestion>; // can be either }This addresses the original use case but doesn't help if async parse() is
Option E: fp-ts style HKT simulation
ever needed.Use the technique from fp-ts to simulate Higher-Kinded Types:
interface URItoKind<A> { Identity: A; Promise: Promise<A>; } type Kind<F extends keyof URItoKind<any>, A> = URItoKind<A>[F]; interface Parser<F extends keyof URItoKind<any>, TValue, TState> { parse(context: ParserContext<TState>): Kind<F, ParserResult<TState>>; }The most flexible approach, but with a steep learning curve.
Testing the ideaRather than commit to an approach based on theoretical analysis, I created
a prototype to test how well TypeScript handles the type inference in practice.
I published my findings in the GitHub issue:Both approaches correctly handle the “any async → all async” rule at the
type level. (…) Complex conditional types like
ModeValue<CombineParserModes<T>, ParserResult<TState>> sometimes require
explicit type casting in the implementation. This only affects library
internals. The user-facing API remains clean.The prototype validated that Option B (explicit mode parameter with default)
Backward compatible: The default "sync" keeps existing code working Explicit: The mode is visible in both types and runtime (via a $mode
would work. I chose it for these reasons:
property) Debuggable: Easy to inspect the current mode at runtime Better IDE support: Type information is more predictable How CombineModes worksThe CombineModes type computes whether a combined parser should be sync or
type CombineModes<T extends readonly Mode[]> = "async" extends T[number] ? "async" : "sync";
async:This type checks if "async" is present anywhere in the tuple of modes.
If so, the result is "async"; otherwise, it's "sync".For combinators like object(), I needed to extract modes from parser
// Extract the mode from a single parser type ParserMode<T> = T extends Parser<infer M, unknown, unknown> ? M : never; // Combine modes from all values in a record of parsers type CombineObjectModes<T extends Record<string, Parser<Mode, unknown, unknown>>> = CombineModes<{ [K in keyof T]: ParserMode<T[K]> }[keyof T][]>; Runtime implementation
objects and combine them:The type system handles compile-time safety, but the implementation also needs
const syncParser = option("-n", "--name", string()); console.log(syncParser.$mode); // "sync" const asyncParser = option("-b", "--branch", asyncValueParser); console.log(asyncParser.$mode); // "async"
runtime logic. Each parser has a $mode property that indicates its execution
mode:Combinators compute their mode at construction time:
function object<T extends Record<string, Parser<Mode, unknown, unknown>>>( parsers: T ): Parser<CombineObjectModes<T>, ObjectValue<T>, ObjectState<T>> { const parserKeys = Reflect.ownKeys(parsers); const combinedMode: Mode = parserKeys.some( (k) => parsers[k as keyof T].$mode === "async" ) ? "async" : "sync"; // ... implementation } Refining the APILucas suggested an important refinement during our
discussion. Instead of having run() automatically choose between sync and
async based on the parser mode, he proposed separate functions:Perhaps run(…) could be automatic, and runSync(…) and runAsync(…) could
enforce that the inferred type matches what is expected.So we ended up with:
run(): automatic based on parser mode runSync(): enforces sync mode at compile time runAsync(): enforces async mode at compile time // Automatic: returns T for sync parsers, Promise<T> for async const result1 = run(syncParser); // string const result2 = run(asyncParser); // Promise<string> // Explicit: compile-time enforcement const result3 = runSync(syncParser); // string const result4 = runAsync(asyncParser); // Promise<string> // Compile error: can't use runSync with async parser const result5 = runSync(asyncParser); // Type error!I applied the same pattern to parse()/parseSync()/parseAsync() and
Creating async value parsers
suggest()/suggestSync()/suggestAsync() in the facade functions.With the new API, creating an async value parser for Git branches looks
import type { Suggestion } from "@optique/core/parser"; import type { ValueParser, ValueParserResult } from "@optique/core/valueparser"; function gitRef(): ValueParser<"async", string> { return { $mode: "async", metavar: "REF", parse(input: string): Promise<ValueParserResult<string>> { return Promise.resolve({ success: true, value: input }); }, format(value: string): string { return value; }, async *suggest(prefix: string): AsyncIterable<Suggestion> { const { $ } = await import("bun"); const [branches, tags] = await Promise.all([ $`git for-each-ref --format='%(refname:short)' refs/heads/`.text(), $`git for-each-ref --format='%(refname:short)' refs/tags/`.text(), ]); for (const ref of [...branches.split("\n"), ...tags.split("\n")]) { const trimmed = ref.trim(); if (trimmed && trimmed.startsWith(prefix)) { yield { kind: "literal", text: trimmed }; } } }, }; }
like this:Notice that parse() returns Promise.resolve() even though it's synchronous.
This is because the ValueParser<"async", T> type requires all methods to use
async signatures. Lucas pointed out this is a minor ergonomic issue. If only
suggest() needs to be async, you still have to wrap parse() in a Promise.I considered per-method mode granularity (e.g., ValueParser<ParseMode, SuggestMode, T>), but the implementation complexity would multiply
// Option 1: Use Promise.resolve() parse(input) { return Promise.resolve({ success: true, value: input }); } // Option 2: Mark as async and suppress the linter // biome-ignore lint/suspicious/useAwait: sync implementation in async ValueParser async parse(input) { return { success: true, value: input }; } What it cost
substantially. For now, the workaround is simple enough:Supporting dual modes added significant complexity to Optique's internals.
Type signatures grew more complex with mode parameters Mode propagation logic had to be added to every combinator Dual implementations were needed for sync and async code paths Type casts were sometimes necessary in the implementation to satisfy
Every combinator needed updates:
TypeScriptFor example, the object() combinator went from around 100 lines to around
if (combinedMode === "async") { return { $mode: "async" as M, // ... async implementation with Promise chains async parse(context) { // ... await each field's parse result }, }; } else { return { $mode: "sync" as M, // ... sync implementation parse(context) { // ... directly call each field's parse }, }; }
250 lines. The internal implementation uses conditional logic based on the
combined mode:This duplication is the cost of supporting both modes without runtime overhead
Lessons learned Listen to users, but validate with prototypes
for sync-only use cases.My initial instinct was to resist async support. Lucas's persistence and
Backward compatibility is worth the complexity
concrete examples changed my mind, but I validated the approach with a
prototype before committing. The prototype revealed practical issues (like
TypeScript inference limits) that pure design analysis would have missed.Making "sync" the default mode meant existing code continued to work
Unified mode vs per-method granularity
unchanged. This was a deliberate choice. Breaking changes should require
user action, not break silently.I chose unified mode (all methods share the same sync/async mode) over
Designing in public
per-method granularity. This means users occasionally write
Promise.resolve() for methods that don't actually need async, but the
alternative was multiplicative complexity in the type system.The entire design process happened in a public GitHub issue. Lucas, Giuseppe,
Conclusion
and others contributed ideas that shaped the final API. The
runSync()/runAsync() distinction came directly from Lucas's feedback.This was one of the more challenging features I've implemented in Optique.
TypeScript's type system is powerful enough to encode the “any async means all
async” rule at compile time, but getting there required careful design work and
prototyping.What made it work: conditional types like ModeValue<M, T> can bridge the gap
between sync and async worlds. You pay for it with implementation complexity,
but the user-facing API stays clean and type-safe.Optique 0.9.0 with async support is currently in pre-release testing. If
npm add @optique/core@0.9.0-dev.212 @optique/run@0.9.0-dev.212 deno add --jsr @optique/core@0.9.0-dev.212 @optique/run@0.9.0-dev.212
you'd like to try it, check out PR #70 or install the pre-release:Feedback is welcome!
-
@eldritch48 what's even the excuse behind this decision?
-
-
@futurebird here it's customary to have lentils
Post suggeriti
-
#MerryChristmas and a Happy #Caturday!
Watching Ignoring Scheduled Pinned Locked Moved Uncategorized cat cats caturday pixelcats christmas holidays merrychristmas christmastrees
1
0 Votes1 Posts1 Views -
Excuse me.
Watching Ignoring Scheduled Pinned Locked Moved Uncategorized caturday cats catsofmastodon
1
0 Votes2 Posts9 Views -
Sleepy little Puffadoodle loves her scritchies
Watching Ignoring Scheduled Pinned Locked Moved Uncategorized cats catsofmastodon0 Votes1 Posts12 Views -
I often wish I had a cat cave like hers
Watching Ignoring Scheduled Pinned Locked Moved Uncategorized cats catsofmastodon torties
1
0 Votes1 Posts9 Views