Skip to content

Commit 718cc94

Browse files
committed
refactor: pass typed parsed variant to mode handlers instead of Extract<> casts
ModeHandler<T> now receives ParsedVariant<T> as an argument. The dispatcher passes the correctly-narrowed parsed value, so override closures can access variant-specific fields (.org, .projectSlug) directly without manual casts.
1 parent 39ac926 commit 718cc94

File tree

3 files changed

+65
-51
lines changed

3 files changed

+65
-51
lines changed

src/commands/issue/list.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,7 @@ import {
1313
listIssuesPaginated,
1414
listProjects,
1515
} from "../../lib/api-client.js";
16-
import {
17-
type ParsedOrgProject,
18-
parseOrgProjectArg,
19-
} from "../../lib/arg-parsing.js";
16+
import { parseOrgProjectArg } from "../../lib/arg-parsing.js";
2017
import { buildCommand } from "../../lib/command.js";
2118
import {
2219
clearPaginationCursor,
@@ -699,10 +696,10 @@ export const listCommand = buildCommand({
699696
"auto-detect": resolveAndHandle,
700697
explicit: resolveAndHandle,
701698
"project-search": resolveAndHandle,
702-
"org-all": () =>
699+
"org-all": (p) =>
703700
handleOrgAllIssues({
704701
stdout,
705-
org: (parsed as Extract<ParsedOrgProject, { type: "org-all" }>).org,
702+
org: p.org,
706703
flags,
707704
setContext,
708705
}),

src/commands/project/list.ts

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -673,14 +673,10 @@ export const listCommand = buildCommand({
673673
parsed,
674674
overrides: {
675675
"auto-detect": () => handleAutoDetect(stdout, cwd, flags),
676-
explicit: () => {
677-
const p = parsed as Extract<ParsedOrgProject, { type: "explicit" }>;
678-
return handleExplicit(stdout, p.org, p.project, flags);
679-
},
680-
"org-all": () => {
676+
explicit: (p) => handleExplicit(stdout, p.org, p.project, flags),
677+
"org-all": (p) => {
681678
// Build context key and resolve cursor only in org-all mode, after
682679
// dispatchOrgScopedList has already validated --cursor is allowed here.
683-
const p = parsed as Extract<ParsedOrgProject, { type: "org-all" }>;
684680
const contextKey = buildContextKey(parsed, flags, getApiBaseUrl());
685681
const cursor = resolveCursor(flags.cursor, contextKey);
686682
return handleOrgAll({
@@ -691,13 +687,8 @@ export const listCommand = buildCommand({
691687
cursor,
692688
});
693689
},
694-
"project-search": () => {
695-
const p = parsed as Extract<
696-
ParsedOrgProject,
697-
{ type: "project-search" }
698-
>;
699-
return handleProjectSearch(stdout, p.projectSlug, flags);
700-
},
690+
"project-search": (p) =>
691+
handleProjectSearch(stdout, p.projectSlug, flags),
701692
},
702693
});
703694
},

src/lib/org-list.ts

Lines changed: 58 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -125,22 +125,39 @@ export type OrgListConfig<TEntity, TWithOrg> = ListCommandMeta & {
125125
// Mode handler types
126126
// ---------------------------------------------------------------------------
127127

128-
/** A single dispatch handler — a zero-argument async function. */
129-
export type ModeHandler = () => Promise<void>;
128+
/** Extract a specific variant from the {@link ParsedOrgProject} union by its `type` discriminant. */
129+
export type ParsedVariant<T extends ParsedOrgProject["type"]> = Extract<
130+
ParsedOrgProject,
131+
{ type: T }
132+
>;
133+
134+
/**
135+
* A dispatch handler that receives the correctly-narrowed parsed variant.
136+
* The dispatcher guarantees `parsed.type` matches the handler key, so
137+
* callers can safely access variant-specific fields (e.g. `.org`, `.projectSlug`)
138+
* without runtime checks or manual casts.
139+
*/
140+
export type ModeHandler<
141+
T extends ParsedOrgProject["type"] = ParsedOrgProject["type"],
142+
> = (parsed: ParsedVariant<T>) => Promise<void>;
130143

131144
/**
132145
* Complete handler map — one handler per parsed target type.
133-
* Keys match `ParsedOrgProject["type"]`.
146+
* Each handler receives the corresponding {@link ParsedVariant}.
134147
*/
135-
export type ModeHandlerMap = Record<ParsedOrgProject["type"], ModeHandler>;
148+
export type ModeHandlerMap = {
149+
[K in ParsedOrgProject["type"]]: ModeHandler<K>;
150+
};
136151

137152
/**
138153
* Partial handler map for overriding specific dispatch modes.
139154
*
140155
* Provide only the modes you need to customise; the rest will use
141156
* the default handlers from {@link buildDefaultHandlers}.
142157
*/
143-
export type ModeOverrides = Partial<ModeHandlerMap>;
158+
export type ModeOverrides = {
159+
[K in ParsedOrgProject["type"]]?: ModeHandler<K>;
160+
};
144161

145162
// ---------------------------------------------------------------------------
146163
// Type guard
@@ -580,30 +597,34 @@ type DefaultHandlerOptions<TEntity, TWithOrg> = {
580597
stdout: Writer;
581598
cwd: string;
582599
flags: BaseListFlags;
583-
parsed: ParsedOrgProject;
584600
};
585601

586602
/**
587603
* Build the default `ModeHandlerMap` for the given config and request context.
588604
*
605+
* Each handler receives the correctly-narrowed {@link ParsedVariant} for its mode,
606+
* so it can access variant-specific fields (`.org`, `.projectSlug`) without casts.
607+
*
589608
* If `config` is only {@link ListCommandMeta} (not a full {@link OrgListConfig}),
590609
* each default handler throws when invoked — this only happens if a mode is not
591610
* covered by the caller's overrides, which would be a programming error.
592611
*/
593612
function buildDefaultHandlers<TEntity, TWithOrg>(
594613
options: DefaultHandlerOptions<TEntity, TWithOrg>
595614
): ModeHandlerMap {
596-
const { config, stdout, cwd, flags, parsed } = options;
615+
const { config, stdout, cwd, flags } = options;
597616

598-
const notSupported =
599-
(mode: string): ModeHandler =>
600-
() =>
617+
function notSupported<T extends ParsedOrgProject["type"]>(
618+
mode: string
619+
): ModeHandler<T> {
620+
return () =>
601621
Promise.reject(
602622
new Error(
603623
`No handler for '${mode}' mode in '${config.commandPrefix}'. ` +
604624
"Provide a full OrgListConfig or an override for this mode."
605625
)
606626
);
627+
}
607628

608629
if (!isOrgListConfig(config)) {
609630
// Metadata-only config — all modes must be overridden by the caller
@@ -615,49 +636,47 @@ function buildDefaultHandlers<TEntity, TWithOrg>(
615636
};
616637
}
617638

618-
const contextKey = buildOrgContextKey(
619-
parsed.type === "org-all" ? parsed.org : ""
620-
);
621-
622639
return {
623640
"auto-detect": () => handleAutoDetect(config, stdout, cwd, flags),
624641

625-
explicit: () => {
642+
explicit: (parsed) => {
626643
if (config.listForProject) {
627644
return handleExplicitProject({
628645
config,
629646
stdout,
630-
org: parsed.type === "explicit" ? parsed.org : "",
631-
project: parsed.type === "explicit" ? parsed.project : "",
647+
org: parsed.org,
648+
project: parsed.project,
632649
flags,
633650
});
634651
}
635652
// No project-scoped API — fall back to org listing with a note
636653
return handleExplicitOrg({
637654
config,
638655
stdout,
639-
org: parsed.type === "explicit" ? parsed.org : "",
656+
org: parsed.org,
640657
flags,
641658
noteOrgScoped: true,
642659
});
643660
},
644661

645-
"project-search": () =>
646-
handleProjectSearch(
647-
config,
648-
stdout,
649-
parsed.type === "project-search" ? parsed.projectSlug : "",
650-
flags
651-
),
662+
"project-search": (parsed) =>
663+
handleProjectSearch(config, stdout, parsed.projectSlug, flags),
652664

653-
"org-all": () => {
654-
const org = parsed.type === "org-all" ? parsed.org : "";
665+
"org-all": (parsed) => {
666+
const contextKey = buildOrgContextKey(parsed.org);
655667
const cursor = resolveOrgCursor(
656668
flags.cursor,
657669
config.paginationKey,
658670
contextKey
659671
);
660-
return handleOrgAll({ config, stdout, org, flags, contextKey, cursor });
672+
return handleOrgAll({
673+
config,
674+
stdout,
675+
org: parsed.org,
676+
flags,
677+
contextKey,
678+
cursor,
679+
});
661680
},
662681
};
663682
}
@@ -683,10 +702,13 @@ export type DispatchOptions<TEntity = unknown, TWithOrg = unknown> = {
683702
};
684703

685704
/**
686-
* Validate the cursor flag and dispatch to the correct handler.
705+
* Validate the cursor flag and dispatch to the correct mode handler.
687706
*
688707
* Merges default handlers with caller-provided overrides using
689-
* `{ ...defaults, ...overrides }`, then invokes `handlers[parsed.type]()`.
708+
* `{ ...defaults, ...overrides }`, then invokes `handlers[parsed.type](parsed)`.
709+
* Each handler receives the correctly-narrowed {@link ParsedVariant} for its mode,
710+
* eliminating the need for `Extract<>` casts at call sites.
711+
*
690712
* This is the single entry point for all org-scoped list commands.
691713
*/
692714
export async function dispatchOrgScopedList<TEntity, TWithOrg>(
@@ -704,8 +726,12 @@ export async function dispatchOrgScopedList<TEntity, TWithOrg>(
704726
);
705727
}
706728

707-
const defaults = buildDefaultHandlers({ config, stdout, cwd, flags, parsed });
729+
const defaults = buildDefaultHandlers({ config, stdout, cwd, flags });
708730
const handlers: ModeHandlerMap = { ...defaults, ...overrides };
709731

710-
await handlers[parsed.type]();
732+
// TypeScript cannot prove that `parsed` narrows to `ParsedVariant<typeof parsed.type>`
733+
// through the indexed access `handlers[parsed.type]`, but the handler map guarantees
734+
// each key maps to a handler expecting exactly that variant.
735+
// biome-ignore lint/suspicious/noExplicitAny: safe — dispatch guarantees type match
736+
await (handlers[parsed.type] as ModeHandler<any>)(parsed);
711737
}

0 commit comments

Comments
 (0)