10LOC
TypeScriptintermediate

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

  • Token is a plain discriminated union of three object shapes, discriminated on kindExtract and Exclude both 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 of Token whose shape fits { kind: "comment" } — here, just the comment variant — so a true result narrows t to that one shape.
  • parseConfig's return type, Exclude<Token, { kind: "comment" }>[], is Extract's mirror image: every member of Token except 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 : never for Extract, the inverted branches for Exclude) — that's why handing the three-member Token union to either one produces the right subset without looping over the members by hand.
  • The filter callback restates its own type predicate, (t): t is Exclude<Token, { kind: "comment" }>isComment alone only tells TypeScript "this value is a comment"; filter needs its own predicate to carry that narrowing into the returned array's element type.

Gotchas

  • Extract and Exclude match structurally, not by name. A typo like { kind: "coment" } wouldn't error — it would silently produce never, since no member of Token has that shape.
  • Negating a type predicate loses the predicate: !isComment(t) is just a plain boolean at the type level, not t is Exclude<Token, { kind: "comment" }>. That's exactly why parseConfig restates its own predicate on the filter callback 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 nesting Extract/Exclude inside a conditional type of your own.

Related

  • Discriminated unions + exhaustive switchExtract/Exclude narrow a union from the outside; a switch on the same kind field 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.