Conditional types

Explains conditional types in CruftScript, written T extends U ? X : Y to choose a type from a relationship between types. Covers the three-way verdict (true, false, or a hard error when the relation is undecidable), the whole-union rule that replaces TypeScript's automatic distribution, and capturing a piece with infer.

A conditional type chooses one of two types based on a relationship between types. You write it T extends U ? X : Y, and it reads like a ternary: if T extends U, the type is X, otherwise it is Y.

type IsString<T> = T extends string ? "yes" : "no"
type A = IsString<string>   // "yes"
type B = IsString<number>   // "no"

Boolean-literal types (true / false) do not resolve in the checker today, so a conditional's branches use resolvable types — string-literal types like "yes" above, or named object types — rather than the bare true/false verdicts you may know from TypeScript.

This is type-level computation: it runs entirely at compile time to compute one type from others, and it disappears before the program runs. If you have used generics, think of a conditional type as an if that operates on types instead of values. If you have used TypeScript's conditional types, the surface is the same; the differences are in how strictly CruftScript insists on a decidable answer.

One branch, chosen by a three-way verdict

The checker resolves T extends U to one of three verdicts and delivers exactly one branch:

  • TrueT provably extends U. You get X.
  • FalseT provably does not extend U. You get Y.
  • Indeterminate — the checker cannot prove either way. This is a hard error, not a guess. The conditional does not silently pick a branch.

Two cases involving the unknown type are worth memorizing, because they are asymmetric:

type T1 = X extends unknown ? "yes" : "no"   // always true
type T2 = unknown extends X ? "yes" : "no"   // Indeterminate in principle

T extends unknown is always True, because every type fits into unknownunknown is the universal target. unknown extends X is Indeterminate in principle: an unknown value has to be narrowed before use, so the checker should not assert that it extends anything. Note, however, that the Indeterminate rejection is not yet enforced — the checker currently accepts unknown extends X rather than stopping the build. Treat it as undecidable and do not rely on either branch until enforcement lands.

A union on the checked side

When the type on the left of extends is a union, CruftScript uses whole-union semantics: the verdict is True only if every member extends U. It does not split the union apart and test members one at a time.

type T = (string | number) extends string ? "yes" : "no"   // "no": number does not extend string

This is the point where CruftScript deliberately parts from TypeScript, whose bare conditionals distribute over a union automatically. Distribution, where you filter a union member by member, is instead delivered through named utilities so the behavior is explicit at the call site:

  • Exclude<T, U> — drop the members of T that extend U.
  • Extract<T, U> — keep the members of T that extend U.
  • NonNullable<T> — drop null and undefined.

If you want the whole-union verdict even where you might otherwise get distribution, box both sides in a single-element tuple to opt out:

type Boxed<T> = [T] extends [U] ? X : Y   // tests T as a whole, never member by member

Capturing with infer

The true branch can name a piece of the shape it matched using infer, which binds a captured type for use in X:

type ElementOf<T> = T extends Array<infer E> ? E : never
type N = ElementOf<Array<number>>   // number

A conditional may also refer to itself. That is allowed only when the recursion provably terminates; see recursive conditional types for the exact rule.

Why it works this way

A conditional type is a decision, and CruftScript treats a decision it cannot make as an error rather than a coin flip. TypeScript will, in a few corners, take both branches at once or fall through to a permissive result when the relation is unclear. CruftScript refuses: an Indeterminate verdict stops the build with a diagnostic. The whole-union rule follows the same instinct. Automatic distribution is convenient but easy to trigger by accident, so filtering is moved into named utilities where you can see it, and the bare conditional keeps the one meaning you wrote.

Limitations

  • Indeterminate is a hard error. If the checker cannot prove the relation either way, the conditional does not resolve. You give it decidable inputs or you get a diagnostic.
  • No automatic distribution. A bare conditional over a union tests the union as a whole. Use Exclude/Extract/NonNullable for member-by-member filtering, and [T] extends [U] to guarantee the whole-union reading.
  • unknown on the checked side is Indeterminate. unknown extends X is undecidable in principle; the rejection is not yet enforced, so the checker currently accepts it. Do not depend on either branch.
  • Boolean-literal branches do not resolve. Use string-literal types or named object types as a conditional's branches, not bare true/false.