Extract and Exclude in a tiny parser helper
Published July 13, 2027
type Assignment = { kind: "assign"; key: string; value: string };
type Flag = { kind: "flag"; key: string };
type Token = Assignment | Flag | { kind: "comment"; text: string };
const tokenize = (line: string): Token => {
if (line.startsWith("#")) return { kind: "comment", text: line.slice(1).trim() };
const [key, value] = line.split("=");
return value === undefined ? { kind: "flag", key: key.trim() } : { kind: "assign", key: key.trim(), value: value.trim() };
};
const isComment = (t: Token): t is Extract<Token, { kind: "comment" }> => t.kind === "comment";
export const parseConfig = (lines: string[]): Exclude<Token, { kind: "comment" }>[] =>
lines.map(tokenize).filter((t): t is Exclude<Token, { kind: "comment" }> => !isComment(t));What
parseConfig(lines) tokenizes a small config-file grammar — comments starting with #, key=value assignments, bare flags — into a Token union, then uses Extract and Exclude to derive "just the comment shape" and "everything except the comment shape" directly from Token, instead of hand-writing a second type.
Why it matters
A tokenizer like this naturally produces a discriminated union — one object shape per token kind. The moment you need a narrower slice of that union (comments only, to filter them out; everything-but-comments, to hand to the next parsing stage), the tempting shortcut is to write that narrower type by hand next to Token. That's a second copy of the same fields, and it silently drifts out of sync the next time Token gains a new variant. Extract<Token, U> and Exclude<Token, U> derive the narrower type directly from Token — add a fourth token kind later, and both derived types update on their own.
How it works
Tokenis a plain discriminated union of three object shapes, discriminated onkind—ExtractandExcludeboth rely on that discriminant to know which union members match.isComment's return type,t is Extract<Token, { kind: "comment" }>, is a type predicate:Extract<Token, { kind: "comment" }>picks out exactly the member ofTokenwhose shape fits{ kind: "comment" }— here, just the comment variant — so atrueresult narrowstto that one shape.parseConfig's return type,Exclude<Token, { kind: "comment" }>[], isExtract's mirror image: every member ofTokenexcept the one matching{ kind: "comment" }, i.e.Assignment | Flag, computed the same way.- Both types are conditional types that distribute over a union member-by-member automatically (
T extends U ? T : neverforExtract, the inverted branches forExclude) — that's why handing the three-memberTokenunion to either one produces the right subset without looping over the members by hand. - The
filtercallback restates its own type predicate,(t): t is Exclude<Token, { kind: "comment" }>—isCommentalone only tells TypeScript "this value is a comment";filterneeds its own predicate to carry that narrowing into the returned array's element type.
Gotchas
ExtractandExcludematch structurally, not by name. A typo like{ kind: "coment" }wouldn't error — it would silently producenever, since no member ofTokenhas that shape.- Negating a type predicate loses the predicate:
!isComment(t)is just a plainbooleanat the type level, nott is Exclude<Token, { kind: "comment" }>. That's exactly whyparseConfigrestates its own predicate on thefiltercallback instead of relying on!isComment(t)to narrow anything on its own. - Both types distribute member-by-member only when the type parameter is used "naked" in the conditional (
T extends U ? T : never). Wrapping it —[T] extends [U] ? T : never— suppresses distribution. Not a concern in this snippet, but worth knowing before nestingExtract/Excludeinside a conditional type of your own.
Related
- Discriminated unions + exhaustive
switch—Extract/Excludenarrow a union from the outside; aswitchon the samekindfield narrows it from the inside, member by member, with the compiler checking every case is covered. Awaited<T>/ReturnType<T>— other built-in conditional-type utilities, but shaped around unwrapping a single type rather than filtering a union.