Skip to content

Migrating from Then/Else to $Options

Before v8, a branching type took its two outcomes as positional type parameters:

type IsEqual<A, B, Then = true, Else = false> = ...

That shape ran out of room. There was nowhere to put behavioral options such as 🔀 distributive and 📌 exact, and nowhere to name the outcome for a special input such as any or never.

v8 replaces it with a single options object, $O:

type IsEqual<A, B, $O extends IsEqual.$Options = {}> = ...

Then becomes $then and Else becomes $else.

// before
type R = IsString<T, 'yes', 'no'>
// after
type R = IsString<T, { $then: 'yes'; $else: 'no' }>

Everything else the old form could express, it expressed by picking different Then and Else values. Those have shorthands now:

Before After
IsString<T> IsString<T>
IsString<T, T, never> IsString<T, { selection: 'filter' }>
IsString<T, Then, Else> IsString<T, { $then: Then; $else: Else }>

And the options object holds the things the positional form had no room for:

type R1 = IsString<'a', { exact: true }> // false
type R2 = IsString<'a' | 1, { distributive: false }> // false
type R3 = IsNever<any, { $any: 'any'; $then: 'never'; $else: 'no' }> // 'any'

See Options for the full list and type branching for the branch selectors.

The IsXXX predicate family — IsAny, IsArray, IsBigint, IsBoolean, IsFalse, IsFunction, IsNever, IsNull, IsNumber, IsObject, IsString, IsSymbol, IsTrue, IsTuple, IsUndefined, IsUnknown, IsVoid — and Assignable take $O and no longer accept the positional form.

IsEmptyObject, IsRecord and IsDisjoint took no options before 8.0.0 and now take $O. ArrayPlus.IsReadonly moved from the legacy IsReadonly.Options onto $O; its $notArray branch is gone, so write IsArray<A, { $then: IsReadonly<A>; $else: X }> where you passed { $notArray: X }.

IsStrictFunction and IsNotStrictFunction are removed. Use IsFunction<T, { exact: true }> and IsNotFunction<T, { exact: true }> instead.

The last of the positional branching types moved in 8.0.0 too:

Before After
If<Condition, Then, Else> If<Condition, { $then: Then; $else: Else }>
HasKey<T, K, Then, Else> HasKey<T, K, { $then: Then; $else: Else }>
IsOptionalKey<T, K, Then, Else> IsOptionalKey<T, K, { $then: Then; $else: Else }>
IsUnion<T, Then, Else> IsUnion<T, { $then: Then; $else: Else }>
UnionType<T> IsUnion<T, { selection: 'filter' }>
IsIndexOutOfBound<A, N, Then, Else> IsIndexOutOfBound<A, N, { $then: Then; $else: Else }>
StringPlus.Includes<S, Search, Then, Else> StringPlus.Includes<S, Search, { $then: Then; $else: Else }>
Equal IsEqual
IsEqual<A, B, Then, Else> IsEqual<A, B, { $then: Then; $else: Else }>
NotEqual IsNotEqual
IsNotEqual<A, B, Then, Else> IsNotEqual<A, B, { $then: Then; $else: Else }>
IsLiteral<T, Then, Else> IsLiteral<T, { $then: Then; $else: Else }>
Some<A, Criteria, Mode, Then, Else> Some<A, Criteria, { mode: Mode; $then: Then; $else: Else }>
CanAssign<A, B> Assignable<A, B>
StrictCanAssign<A, B> Assignable<A, B, { distributive: false }>
IsAssign<A, B> Assignable<A, B>
Extendable<A, B> Assignable.$<A, B, { selection: 'filter' }>
NotExtendable<A, B> NotAssignable.$<A, B, { selection: 'filter' }>
IsExtend<A, B, Then, Else> Assignable.$<A, B, { $then: Then; $else: Else }>
IsNotExtend<A, B, Then, Else> NotAssignable.$<A, B, { $then: Then; $else: Else }>

UnionType was the one entry that collapsed rather than moved: it defaulted to Then = T, Else = never, which is filter semantics, and $O spells that natively — so the UnionType / IsUnion pair is now one type plus an option.

// before
type R = UnionType<T> // T if T is a union, never otherwise
// after
type R = IsUnion<T, { selection: 'filter' }>

HasKey and IsOptionalKey take the key as the filter subject, so their filter form selects keys: IsOptionalKey<T, keyof T, { selection: 'filter' }> is OptionalKeys<T>.

Some’s Mode parameter folded into $O as mode rather than moving position for position — it now sits beside selection and the branch overrides in the one options object:

// before
type R1 = Some<A, C, 'strict'>
type R2 = Some<A, C, 'loose', 'yes', 'no'>
// after
type R1 = Some<A, C, { mode: 'strict' }>
type R2 = Some<A, C, { $then: 'yes'; $else: 'no' }>

Equal, IsEqual/IsNotEqual/NotEqual (the old positional forms), CanAssign and its StrictCanAssign/IsAssign aliases, and Extendable/NotExtendable/IsExtend/IsNotExtend are gone as of 8.0.0 — there is no deprecation window, the rename above is the only path forward.

IsEqual itself changed behavior along with its shape: two distinct unique symbols are no longer equal, and a unique symbol is no longer equal to symbol. Before 8.0.0, IsEqual (then a deprecated alias) reported both of those as equal. Check symbol comparisons when you migrate.

StringIncludes keeps its positional Then/Else by design — it is the low-level string check, and StringPlus.Includes is the branching type built on top of it.

The LooseArrayType family — LooseArrayType, IsLooseArray, NotLooseArrayType, IsNotLooseArray — is removed in 8.0.0, since IsArray is loose by default and no longer needs a stopgap. Use IsArray / IsNotArray instead. The per-type mapping lives with the array API, under Loose array types.

If you build types on top of type-plus, the same convention is available to you. Declare the options in a namespace next to the type and resolve the branches with $ResolveBranch:

import type { $ResolveBranch, $Selection, $StrictOptions, $Then, $Else } from 'type-plus'
export type IsPositive<T extends number, $O extends $StrictOptions<$O, IsPositive.$Options> = {}> =
`${T}` extends `-${string}` ? $ResolveBranch<$O, [$Else]> : $ResolveBranch<$O, [$Then], T>
export namespace IsPositive {
export interface $Options extends $Selection.Options {}
export type $Branch<$O extends $Options = {}> = $Selection.Branch<$O>
}

Defaulting $O to {} is what keeps the plain IsPositive<1> call form working: with no branches supplied, $ResolveBranch falls back to the predicate result. $StrictOptions rejects any key IsPositive.$Options does not declare; see Unknown option keys and generic wrappers for forwarding a generic $O to another type.