TypeScript smalnar typen till `never` oönskat


Medan jag arbetade med ett projekt på jobbet (TypeScript 5.1.3), stötte jag på följande konvention för att skicka fel genom appens abstraktionslager:

export class Success<E, S> {
  readonly value: S;

  constructor(value: S) {
    this.value = value;
  }

  isSuccess(): this is Success<E, S> {
    return true;
  }

  isError(): this is MyError<E, S> {
    return false;
  }
}

export class MyError<E, S> {
  readonly value: E;

  constructor(value: E) {
    this.value = value;
  }

  isSuccess(): this is Success<E, S> {
    return false;
  }

  isError(): this is MyError<E, S> {
    return true;
  }
}

export type Either<E, S> = MyError<E, S> | Success<E, S>;

export const error = <E, S>(value: E): Either<E, S> => {
  return new MyError(value);
};

export const success = <E, S>(value: S): Either<E, S> => {
  return new Success(value);
};

Så varje funktion kommer att ha en returtyp som använder hjälptypen Either och sedan returnera antingen success(value) eller error(value) och sedan utvärderar den som anropade resultatet med hjälp av .isError() eller isSuccess() funktionerna. Här är ett exempel:

type SomeErrorType = { message: string };

function otherFunction(): Either<SomeErrorType, string> {
  return success('some value');
}

async function main() {
  const result = otherFunction();

  if (result.isError()) {
    // in this case will evaluate to false since otherFunction returns a success("some value")
    console.log('isError');
    return;
  }

  console.log('isSuccess');
  // will evaluate to true
  console.log(result.value);
}
main();

Det som stör mig är att om jag sätter Either-feltypen till any eller unknown och försöker hantera isError() först, så begränsas resultatet till typen never. Exempel:

function otherFunction(): Either<string, string> {
  return success('some value');
}

async function main() {
  const result = otherFunction();

  if (result.isError()) {
    console.log('isError');
    return;
  }

  console.log('isSuccess');
  console.log(result.value);
  // i can't access result.value because result is now narrowed to `never`
}
main();

Jag förstår inte varför detta skulle utvärderas till never och inte any. Om jag sätter Error på Either till boolean smalnar det korrekt av till boolean, men att sätta till string smalnar också resultatet till never.

Svar #1

TypeScripts typesystem är till stor del strukturell, vilket betyder att om två typer har samma struktur eller form (det vill säga, samma medlemmar av samma typer), så betraktar TypeScript de två typerna som samma typ. Och tyvärr skiljer sig din definition av Success<E, S> och MyError<E, S> endast genom att E och S är utbytta. Det vill säga, TypeScript har inget sätt att skilja mellan Success<T, U> och MyError<U, T>. Och Either<T, T> är unionstypen Success<T, T> | MyError<T, T>, vilket skrivs som två typer, men kollapsar till bara en.

Detta är ett dåligt designval i ett strukturellt typ-system. Du bör lägga till en särskiljande medlem till Success och MyError, så här:

export class Success<E, S> {
  readonly success = true; // true, structurally distinct from MyError
  // ✂ ⋯ snip ⋯ ✂
}

export class MyError<E, S> {
  readonly success = false; // false, structurally distinct from Success
  // ✂ ⋯ snip ⋯ ✂
}

Här har jag lagt till en success-egenskap på båda, där den ena är den bokstavliga typen true och den andra är den bokstavliga typen false. Dessa är olika typer, så TypeScript kommer inte längre förväxla Success<T, U> och MyError<U, T>.

Det kommer att lösa ditt problem, men frågan handlar om varför detta händer, så jag kommer att fortsätta förklara det.


Som jag nämnde, är din Success<T, U> och MyError<U, T> strukturellt identiska. Men det är värre än så. Success<E, S> är kovariant i S och helt oberoende av E och därför bivariant i E (se Skillnad mellan varians, kovarians, kontravarians, bivarians och invarians i TypeScript). Så Success<E1, S1> extends Success<E2, S2> när S1 extends S2 och MyError<E1, S1> extends MyError<E2, S2> när E1 extends E2. Så det är väldigt lätt för TypeScript att förväxla dem. Success<E1, S1> extends MyError<E2, S2> närhelst E1 extends S2 eller S1 extends E2, och det betyder att Either<E, S> faktiskt inte är en union av två distinkta typer, närhelst E extends S eller S extends E. Så om S och E är samma, eller en är en bred typ som unknown eller any, absorberar en av typerna i den unionen helt den andra.

Det betyder att om du skriver ett typvillkor som är avsett att avgränsa en Either<E, S> till MyError<E, S>, så när typvillkoret returnerar false, tar TypeScript bort MyError<E, S> från Either<E, S>. Men i situationer där Success<E, S> extends MyError<E, S>, så tar TypeScript bort både Success och Error från unionen. Det avgränsar hela vägen till never.

Det är vad som händer när du har Either<string, string> eller Either<any, string> eller Either<unknown, string>. För Either<string, string> säger TypeScript "tja, om det inte är en MyError<string, string> så kan det inte vara en Success<string, string> heller eftersom de är samma typ." För Either<any, string> säger TypeScript 'tja, om det inte är en MyError<any, string> så kan det inte vara en Success<any, string> eftersom string extends any. Hoppsan.


Återigen, faktumet att MyError och Success vid körning är två olika klasser från två olika deklarationer är inte något som TypeScript verkligen kan "se" enkelt. TypeScripts typsystem är inte nominellt, så det bryr sig vanligtvis inte om två typer deklarerades på olika platser eller med olika namn. Sättet att få TypeScript att se två typer som olika är att ge dem olika struktur. En enda success-egenskap av typen true eller false är tillräcklig för att göra Either<E, S> till en diskriminerad union, men vilken som helst distinkt struktur skulle också fungera.

Lekplatslänk till kod