Jak sprawdzić, czy blok przełączników jest wyczerpujący w języku TypeScript?


100

Mam kod:

enum Color {
    Red,
    Green,
    Blue
}

function getColorName(c: Color): string {
    switch(c) {
        case Color.Red:
            return 'red';
        case Color.Green:
            return 'green';
        // Forgot about Blue
    }

    throw new Error('Did not expect to be here');
}

Zapomniałem zająć się Color.Bluesprawą i wolałbym, aby wystąpił błąd kompilacji. Jak mogę ustrukturyzować mój kod tak, aby TypeScript oznaczał to jako błąd?


1
Chciałem tylko poinformować ludzi, że istnieje dwuwierszowe rozwiązanie od @Carlos Gines, jeśli przewiniesz wystarczająco daleko.
— Noumenon

Odpowiedzi:


129

Aby to zrobić, użyjemy nevertypu (wprowadzonego w TypeScript 2.0), który reprezentuje wartości, które „nie powinny” wystąpić.

Pierwszym krokiem jest napisanie funkcji:

function assertUnreachable(x: never): never {
    throw new Error("Didn't expect to get here");
}

Następnie użyj go w defaultobudowie (lub równoważnie poza przełącznikiem):

function getColorName(c: Color): string {
    switch(c) {
        case Color.Red:
            return 'red';
        case Color.Green:
            return 'green';
    }
    return assertUnreachable(c);
}

W tym momencie zobaczysz błąd:

return assertUnreachable(c);
       ~~~~~~~~~~~~~~~~~~~~~
       Type "Color.Blue" is not assignable to type "never"

Komunikat o błędzie wskazuje przypadki, które zapomniałeś uwzględnić w swoim wyczerpującym przełączniku! Jeśli pominiesz wiele wartości, zobaczysz błąd dotyczący np Color.Blue | Color.Yellow.

Pamiętaj, że jeśli używasz strictNullChecks, będziesz potrzebować tego returnprzed assertUnreachablepołączeniem (w przeciwnym razie jest to opcjonalne).

Jeśli chcesz, możesz stać się trochę bardziej wyszukany. Jeśli na przykład używasz unii rozłącznej, przydatne może być odzyskanie właściwości dyskryminacyjnej w funkcji potwierdzenia na potrzeby debugowania. To wygląda tak:

// Discriminated union using string literals
interface Dog {
    species: "canine";
    woof: string;
}
interface Cat {
    species: "feline";
    meow: string;
}
interface Fish {
    species: "pisces";
    meow: string;
}
type Pet = Dog | Cat | Fish;

// Externally-visible signature
function throwBadPet(p: never): never;
// Implementation signature
function throwBadPet(p: Pet) {
    throw new Error('Unknown pet kind: ' + p.species);
}

function meetPet(p: Pet) {
    switch(p.species) {
        case "canine":
            console.log("Who's a good boy? " + p.woof);
            break;
        case "feline":
            console.log("Pretty kitty: " + p.meow);
            break;
        default:
            // Argument of type 'Fish' not assignable to 'never'
            throwBadPet(p);
    }
}

To fajny wzorzec, ponieważ zyskujesz bezpieczeństwo w czasie kompilacji, aby upewnić się, że obsłużyłeś wszystkie przypadki, których się spodziewałeś. A jeśli rzeczywiście otrzymasz właściwość poza zakresem (np. Jakiś obiekt wywołujący JS wymyślił nowy species), możesz wyrzucić użyteczny komunikat o błędzie.


3
Czy przy strictNullCheckswłączonej opcji nie wystarczy zdefiniować typu zwracanego funkcji jako string, bez potrzeby korzystania z assertUnreachablefunkcji?
— dbandstra

1
@dbandstra Z pewnością możesz to zrobić, ale jako ogólny wzorzec argument assertUnreachable jest bardziej niezawodny. Działa nawet bez, strictNullChecksa także nadal działa, jeśli istnieją warunki, w których chcesz zwrócić undefined spoza przełącznika.
— Letharion

Wydaje się, że to nie działa, gdy wyliczenie jest zdefiniowane w zewnętrznym pliku d.ts. Przynajmniej nie mogę zmusić go do pracy z enum Office.MailboxEnums.RecipientType z interfejsu API dodatku Microsoft Office JS.
— Søren Boisen

powyższe rozwiązanie działa na podstawowym przykładzie w oryginalnym opisie, jednak nie jestem pewien, czy jest to ogólnie stosowany wzór. Zamień wyliczenie koloru na type InDiscriminant = { a: string } | { b: string }i jak można postępować? Prosty przełącznik nie zna już wszystkich wartości ciągowych i nie ma wspólnych właściwości między typami elementów członkowskich unii do rozróżnienia. Czy istnieje idiomatyczne rozwiązanie w tym przypadku?
— cdaringe

noUnusedParametersKonfiguracja tsconfig zgłosi błąd „x nie jest używany”, który można naprawić, poprzedzając podkreśleniefunction assertUnreachable(_x: never): never { throw new Error("Didn't expect to get here"); }
— jbmilgrom

38

Nie musisz neverniczego używać ani dodawać na końcu swojego switch.

Gdyby

  • Twoje switchoświadczenie zostanie zwrócone w każdym przypadku
  • Masz strictNullCheckswłączoną flagę kompilacji maszynopisu
  • Twoja funkcja ma określony typ zwrotu
  • Zwracany typ to nie undefinedlubvoid

Otrzymasz błąd, jeśli twoje switchoświadczenie nie jest wyczerpujące, ponieważ będzie przypadek, w którym nic nie zostanie zwrócone.

Z twojego przykładu, jeśli to zrobisz

function getColorName(c: Color): string {
    switch(c) {
        case Color.Red:
            return 'red';
        case Color.Green:
            return 'green';
        // Forgot about Blue
    }
}

Otrzymasz następujący błąd kompilacji:

W funkcji brakuje kończącej instrukcji return, a zwracany typ nie zawiera undefined.


To rozwiązanie wymaga jawnego określenia wszystkich możliwych wartości zwracanych funkcji. W innych przypadkach możemy je pominąć i pozwolić kompilatorowi na wnioskowanie o typie.
— Aleksei

tak, kompilator będzie narzekał, ale powinieneś również wziąć pod uwagę, że nadal możesz uzyskać błędną wartość w czasie wykonywania. W takim przypadku wolę wrzucić znaczący komunikat o błędzie (zamiast niejawnie zwracać undefined)
— TmTron

6
Chodzi o to, aby nie wrócić undefined, ale utworzyć brakującą gałąź instrukcji case. W ten sposób nie pojawi się błąd w czasie wykonywania i nie ma potrzeby niczego rzucać.
— Marcelo Lazaroni

3
Kod działa dokładnie tak, jak opisano. Jeśli zignorujesz typ i uznasz, że cmoże to być cokolwiek, nie ma powodu, aby w ogóle używać TypeScript. Jeśli cmogłoby to być zerowe lub nieokreślone, typ powinien to powiedzieć, a implementacja uwzględni to.
— Marcelo Lazaroni

1
To rozwiązanie działa w celu wywołania błędu maszynopisu, ale błąd nie zawsze jest jasny. Wolę używać defaultprzypadku z nieużywaną zmienną wpisaną jako neverz komentarzem wyjaśniającym "hey bud, if you're getting an error here, you forgot to add a case". YMMV.
— Matthias

21

Rozwiązanie

To, co robię, to zdefiniowanie klasy błędu:

export class UnreachableCaseError extends Error {
  constructor(val: never) {
    super(`Unreachable case: ${JSON.stringify(val)}`);
  }
}

a następnie zgłoś ten błąd w przypadku domyślnym:

enum Color {
    Red,
    Green,
    Blue
}

function getColorName(c: Color): string {
  switch(c) {
      case Color.Red:
          return 'red, red wine';
      case Color.Green:
          return 'greenday';
      case Color.Blue:
          return "Im blue, daba dee daba";
      default:
          // Argument of type 'c' not assignable to 'never'
          throw new UnreachableCaseError(c);
  }
}

Myślę, że jest łatwiejszy do odczytania niż podejście funkcji zalecane przez Ryana, ponieważ throwklauzula ma domyślne podświetlanie składni.

Wskazówka

Biblioteka ts-essentials ma klasę UnreachableCaseError dokładnie dla tego przypadku użycia

Uwagi dotyczące czasu wykonywania

Zwróć uwagę, że kod maszynopisu jest transponowany do javascript: w ten sposób wszystkie sprawdzane typy maszynopisów działają tylko w czasie kompilacji i nie istnieją w czasie wykonywania: tj. Nie ma gwarancji, że zmienna cjest rzeczywiście typu Color.
Różni się to od innych języków: np. Java sprawdzi również typy w czasie wykonywania i zwróci znaczący błąd, jeśli spróbujesz wywołać funkcję z argumentem niewłaściwego typu - ale javascript nie.

To jest powód, dla którego ważne jest, aby rzucić znaczący wyjątek w defaultprzypadku: Stackblitz: rzucaj znaczący błąd

Gdybyś tego nie zrobił, funkcja getColorName()niejawnie zwróciłaby undefined(po wywołaniu z nieoczekiwanym argumentem): Stackblitz: return any

W powyższych przykładach bezpośrednio użyliśmy zmiennej typu, anyaby zilustrować problem. Miejmy nadzieję, że nie zdarzy się to w rzeczywistych projektach - ale istnieje wiele innych sposobów, na które można uzyskać zmienną niewłaściwego typu w czasie wykonywania.
Oto kilka, które już widziałem (i sam popełniłem niektóre z tych błędów):

  • używanie form kątowych - te nie są bezpieczne dla typów: wszystkie wartości pól formularza są typu any
    ng-Forms Przykład Stackblitz
  • niejawny dowolny jest dozwolony
  • używana jest wartość zewnętrzna, która nie jest sprawdzana (np. odpowiedź http z serwera jest po prostu przesyłana do interfejsu)
  • odczytujemy wartość z magazynu lokalnego zapisaną przez starszą wersję aplikacji (wartości te uległy zmianie, więc nowa logika nie rozumie starej wartości)
  • używamy bibliotek innych firm, które nie są bezpieczne dla typów (lub po prostu zawierają błąd)

Więc nie bądź leniwy i napisz ten dodatkowy domyślny przypadek - może zaoszczędzić Ci wielu bólów głowy ...


zwróć uwagę, że obecnie nie możesz używać instanceofsprawdzania podklas Error: zobacz numer 13965, w którym również wspomniano o
— obejściu

Sprawdziłem to i teraz działa poprawnie. TypeScript 3.5.1.
— Aleksei

1
Oooo, to jest piękne, a także prawie dokładnie tak, jak bym napisał kod, gdybym pisał w zwykłym JS.
— Pete

Jak kompilator to znajduje?
— wesoły

@jocull nie jestem pewien, o co prosisz. Może chcesz przeczytać o związkach dyskryminowanych ?
— TmTron

20

Opierając się na odpowiedzi Ryana, odkryłem tutaj, że nie ma potrzeby stosowania żadnej dodatkowej funkcji. Możemy zrobić bezpośrednio:

function getColorName(c: Color): string {
  switch (c) {
    case Color.Red:
      return "red";
    case Color.Green:
      return "green";
    // Forgot about Blue
    default:
      const exhaustiveCheck: never = c;
      throw new Error(`Unhandled color case: ${exhaustiveCheck}`);
  }
}

Możesz zobaczyć to w akcji tutaj w TS Playground

Edycja: zawiera sugestię, aby uniknąć „nieużywanych zmiennych” komunikatów linter.


3
Wolę też używać zmiennej, zamiast tworzyć funkcję (chociaż obie mogą zostać usunięte w czasie tworzenia pakietu). W każdym razie twój linter może narzekać na nieużywaną zmienną, a będziesz musiał dodać coś takiego powyżej:// eslint-disable-next-line @typescript-eslint/no-unused-vars
— Matthias

3
Lub możesz użyć zmiennej w throw new Error(`Unexpected: ${_exhaustiveCheck}`);
— zgłaszanym

1
Dobry pomysł. Możesz także zdefiniować anonimową funkcję:default: ((x: never) => { throw new Error(c + " was unhandled."); })(c);
— Brady Holt

Mogę potwierdzić, że rozwiązanie @Nooodles spełnia wymagania sprawdzania nieużywanych zmiennych SonarQube. Dzięki. Uwielbiam, jakie to zwięzłe.
— Noumenon

Odpowiedziałem na niektóre powiązane (myślę, że mogą to być dość częste) błędy z powyższego kodu w następujący sposób:default: { const pleaseBeExhaustive: never = c; throw new Error(`Use all Color - ${pleaseBeExhaustive as string}`); }
— Jonny


6

Miłym neverdodatkiem do odpowiedzi Ryana jest zastąpienie go dowolnym ciągiem znaków, aby komunikat o błędzie był bardziej przyjazny dla użytkownika.

function assertUnreachable(x: 'error: Did you forget to handle this type?'): never {
    throw new Error("Didn't expect to get here");
}

Teraz otrzymujesz:

return assertUnreachable(c);
       ~~~~~~~~~~~~~~~~~~~~~
       Type "Color.Blue" is not assignable to type "error: Did you forget to handle this type?"

To działa, ponieważ nevermożna go przypisać do wszystkiego, w tym dowolnego ciągu.


3

Opierając się na odpowiedziach Ryana i Carlosa , możesz użyć anonimowej metody, aby uniknąć konieczności tworzenia oddzielnej nazwanej funkcji:

function getColorName(c: Color): string {
  switch (c) {
    case Color.Red:
      return "red";
    case Color.Green:
      return "green";
    // Forgot about Blue
    default:
      ((x: never) => {
        throw new Error(`${x} was unhandled!`);
      })(c);
  }
}

Jeśli przełącznik nie jest wyczerpujący, pojawi się błąd czasu kompilacji .


2

Aby uniknąć ostrzeżeń Typescript lub linter:

    default:
        ((_: never): void => {})(c);

w kontekście:

function getColorName(c: Color): string {
    switch(c) {
        case Color.Red:
            return 'red';
        case Color.Green:
            return 'green';
        default:
            ((_: never): void => {})(c);
    }
}

Różnica między tym rozwiązaniem a innymi jest taka

  • nie ma nazwanych zmiennych bez odniesień
  • nie zgłasza wyjątku, ponieważ Typescript wymusi neverwykonanie kodu i tak

Wyjaśnij szczegółowo, co robi kod i czym różni się od innych odpowiedzi
— Sujitmohanty30

1

W naprawdę prostych przypadkach, gdy wystarczy zwrócić jakiś ciąg przez wartość wyliczenia, łatwiej (IMHO) użyć stałej do przechowywania słownika wyników zamiast używać przełącznika. Na przykład:

enum Color {
    Red,
    Green,
    Blue
}

function getColorName(c: Color): string {
  const colorNames: Record<Color, string> = {
    [Color.Red]: `I'm red`,
    [Color.Green]: `I'm green`,
    [Color.Blue]: `I'm blue, dabudi dabudai`,   
  }

  return colorNames[c] || ''
}

Więc tutaj będziesz musiał wymienić każdą wartość wyliczenia w stałej, w przeciwnym razie pojawi się błąd, na przykład, jeśli brakuje niebieskiego:

TS2741: Brak właściwości „Niebieski” w typie „{[Color.Red]: string; [Color.Green]: string; ' ale wymagane w typie „Record”.

Jednak często tak nie jest i wtedy naprawdę lepiej jest rzucić błąd, tak jak zaproponował Ryan Cavanaugh.

Byłem też trochę zdenerwowany, gdy odkryłem, że to również nie zadziała:

function getColorName(c: Color): string {
    switch(c) {
        case Color.Red:
            return 'red';
        case Color.Green:
            return 'green';
    }
    return '' as never // I had some hope that it rises a type error, but it doesn't :)
}

-1

Utwórz funkcję niestandardową zamiast używać switchinstrukcji.

export function exhaustSwitch<T extends string, TRet>(
  value: T,
  map: { [x in T]: () => TRet }
): TRet {
  return map[value]();
}

Przykładowe użycie

type MyEnum = 'a' | 'b' | 'c';

const v = 'a' as MyEnum;

exhaustSwitch(v, {
  a: () => 1,
  b: () => 1,
  c: () => 1,
});

Jeśli później dodasz ddo MyEnum, pojawi się błądProperty 'd' is missing in type ...


Działa to tylko wtedy, gdy wartości wyliczenia są ciągami, ponieważ muszą być zakodowane jako klucze w parametrze obiektu map.
— Will Madden
Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.