Làm cách nào để kiểm tra xem một khối chuyển mạch là đầy đủ trong TypeScript?


98

Tôi có một số mã:

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');
}

Tôi đã quên xử lý Color.Bluetrường hợp và tôi muốn gặp lỗi biên dịch. Làm cách nào để cấu trúc mã của tôi sao cho TypeScript gắn cờ đây là lỗi?


1
Chỉ muốn thông báo với mọi người rằng có một giải pháp hai dòng từ @Carlos Gines nếu bạn cuộn xuống đủ xa.
— Noumenon

Câu trả lời:


122

Để làm điều này, chúng tôi sẽ sử dụng neverkiểu (được giới thiệu trong TypeScript 2.0) đại diện cho các giá trị "không nên" xảy ra.

Bước đầu tiên là viết một hàm:

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

Sau đó, sử dụng nó trong defaulttrường hợp (hoặc tương đương, bên ngoài công tắc):

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

Tại thời điểm này, bạn sẽ thấy lỗi:

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

Thông báo lỗi cho biết các trường hợp bạn quên đưa vào công tắc toàn diện của mình! Nếu bạn bỏ qua nhiều giá trị, bạn sẽ thấy lỗi về ví dụ Color.Blue | Color.Yellow.

Lưu ý rằng nếu bạn đang sử dụng strictNullChecks, bạn sẽ cần nó returntrước assertUnreachablecuộc gọi (nếu không thì tùy chọn).

Bạn có thể nhận được một chút huyền ảo nếu bạn thích. Ví dụ: nếu bạn đang sử dụng liên minh phân biệt đối xử, nó có thể hữu ích để khôi phục thuộc tính phân biệt trong hàm xác nhận cho mục đích gỡ lỗi. Nó trông như thế này:

// 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);
    }
}

Đây là một mô hình hay vì bạn có được sự an toàn về thời gian biên dịch để đảm bảo rằng bạn đã xử lý tất cả các trường hợp bạn mong đợi. Và nếu bạn nhận được một thuộc tính thực sự nằm ngoài phạm vi (ví dụ: một số trình gọi JS được tạo mới species), bạn có thể đưa ra một thông báo lỗi hữu ích.


3
Với việc strictNullChecksđược kích hoạt, nó không đủ để xác định kiểu trả về của hàm là stringmà không cần một assertUnreachablehàm nào cả?
— dbandstra

1
@dbandstra Bạn chắc chắn có thể làm điều đó, nhưng là một mẫu chung, khẳng địnhUnreachable đáng tin cậy hơn. Nó hoạt động ngay cả khi không có strictNullChecksvà cũng tiếp tục hoạt động nếu có các điều kiện mà bạn muốn trả về không xác định từ bên ngoài công tắc.
— Letharion

Điều này dường như không hoạt động khi enum được xác định trong tệp d.ts bên ngoài. Ít nhất thì tôi không thể làm cho nó hoạt động với enum Office.MailboxEnums.RecipientType từ API bổ trợ Microsofts Office JS.
— Søren Boisen 18/09/18

giải pháp trên phù hợp với ví dụ cơ bản trong mô tả ban đầu, tuy nhiên, tôi không chắc rằng đó là một mẫu áp dụng chung. Đổi màu enum với type InDiscriminant = { a: string } | { b: string }và làm thế nào để tiến hành? Một công tắc đơn giản không còn biết tất cả các giá trị chuỗi và không có thuộc tính chung giữa các loại thành viên của liên minh để phân biệt đối xử. Có một giải pháp thành ngữ cho trường hợp này?
— cdaringe,

noUnusedParameterstsconfig thiết lập sẽ ném ra một lỗi "x is never used" mà bạn có thể khắc phục bằng cách thêm dấu gạch dưới vào tiền tốfunction assertUnreachable(_x: never): never { throw new Error("Didn't expect to get here"); }
— jbmilgrom

34

Bạn không cần phải sử dụng neverhoặc thêm bất cứ điều gì vào cuối của bạn switch.

Nếu

  • switchBáo cáo của bạn trả về trong mỗi trường hợp
  • Bạn đã strictNullChecksbật cờ biên dịch tập chữ
  • Hàm của bạn có kiểu trả về được chỉ định
  • Loại trả lại không phải là undefinedhoặcvoid

Bạn sẽ gặp lỗi nếu switchbáo cáo của bạn không đầy đủ vì sẽ có trường hợp không có gì được trả lại.

Từ ví dụ của bạn, nếu bạn làm

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

Bạn sẽ gặp lỗi biên dịch sau:

Hàm thiếu câu lệnh trả về kết thúc và kiểu trả về không bao gồm undefined.


Giải pháp này yêu cầu xác định rõ ràng tất cả các giá trị trả về có thể có của một hàm. Trong các trường hợp khác, chúng ta có thể bỏ qua chúng và để trình biên dịch thực hiện suy luận kiểu.
— Aleksei

có, trình biên dịch sẽ phàn nàn, nhưng bạn cũng nên cân nhắc, rằng bạn vẫn có thể nhận được giá trị sai trong thời gian chạy. Trong trường hợp này, tôi thích để ném một ý nghĩa lỗi tin nhắn (thay vì mặc nhiên việc trở về của undefined)
— TmTron

6
Ý tưởng không phải là quay trở lại undefined, mà là tạo nhánh còn thiếu của câu lệnh trường hợp. Bằng cách này, bạn sẽ không gặp lỗi trong thời gian chạy và không cần phải ném bất cứ thứ gì.
— Marcelo Lazaroni

3
Mã hoạt động chính xác như mô tả. Nếu bạn bỏ qua kiểu và cho rằng đó ccó thể là bất cứ thứ gì thì không có lý do gì để sử dụng TypeScript cả. Nếu ccó thể là null hoặc không xác định, thì kiểu sẽ nói lên điều đó và việc triển khai sau đó sẽ tính đến điều đó.
— Marcelo Lazaroni

1
Giải pháp này hoạt động để kích hoạt lỗi sắp chữ, nhưng lỗi không phải lúc nào cũng rõ ràng. Tôi thích sử dụng một defaulttrường hợp với một biến không sử dụng được nhập như nevervới một chú thích giải thích "hey bud, if you're getting an error here, you forgot to add a case". YMMV.
— Matthias

21

Giải pháp

Những gì tôi làm là xác định một lớp lỗi:

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

và sau đó đặt lỗi này trong trường hợp mặc định:

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);
  }
}

Tôi nghĩ nó dễ đọc hơn so với cách tiếp cận hàm do Ryan đề xuất, vì throwmệnh đề có tô sáng cú pháp mặc định.

Dấu

Các ts-yếu tố cần thiết thư viện có một lớp UnreachableCaseError chính xác cho use-case này

Cân nhắc về thời gian chạy

Lưu ý rằng mã typecript được chuyển sang javascript: Vì vậy, tất cả các typecript typecript chỉ hoạt động tại thời điểm biên dịch và không tồn tại trong thời gian chạy: tức là không có gì đảm bảo rằng biến cthực sự là kiểu Color.
Điều này khác với các ngôn ngữ khác: ví dụ như Java cũng sẽ kiểm tra các kiểu trong thời gian chạy và sẽ gây ra lỗi có ý nghĩa nếu bạn cố gọi hàm với một đối số không đúng kiểu - nhưng javascript thì không.

Đây là lý do tại sao điều quan trọng là phải đưa ra một ngoại lệ có ý nghĩa trong default trường hợp: Stackblitz: ném lỗi có ý nghĩa

Nếu bạn không làm điều này, hàm getColorName() sẽ hoàn toàn trả về undefined(khi được gọi với một đối số không mong muốn): Stackblitz: return any

Trong các ví dụ trên, chúng tôi đã trực tiếp sử dụng một biến kiểu any để minh họa vấn đề. Điều này hy vọng sẽ không xảy ra trong các dự án trong thế giới thực - nhưng có nhiều cách khác, mà bạn có thể nhận được một biến không đúng kiểu trong thời gian chạy.
Đây là một số mà tôi đã thấy (và tôi đã tự mình mắc một số lỗi sau):

  • sử dụng các dạng góc cạnh - những dạng này không an toàn cho kiểu: tất cả các giá trị trường của dạng đều thuộc kiểu any
    ng-form Ví dụ Stackblitz
  • ngầm định bất kỳ được cho phép
  • một giá trị bên ngoài được sử dụng và không được xác thực (ví dụ: phản hồi http từ máy chủ chỉ được truyền tới một giao diện)
  • chúng tôi đọc một giá trị từ bộ nhớ cục bộ mà phiên bản cũ hơn của ứng dụng đã ghi (các giá trị này đã thay đổi, vì vậy logic mới không hiểu giá trị cũ)
  • chúng tôi sử dụng một số lib của bên thứ 3 không an toàn về kiểu chữ (hoặc đơn giản là có lỗi)

Vì vậy, đừng lười biếng và viết trường hợp mặc định bổ sung này - nó có thể giúp bạn an toàn khi rất đau đầu ...


lưu ý rằng bạn hiện không thể sử dụng instanceofkiểm tra cho các lớp con của Error: xem số 13965 cũng đề cập đến cách giải quyết
— TmTron

Tôi đã kiểm tra nó và nó hoạt động chính xác ngay bây giờ. TypeScript 3.5.1.
— Aleksei

1
Ooooh, điều này thật đẹp, và gần như chính xác cách tôi viết mã, nếu tôi viết bằng JS đơn giản.
— Pete

Làm thế nào để trình biên dịch tìm thấy điều này?
— jocull

@jocull không chắc bạn đang yêu cầu gì. Có thể bạn muốn đọc về Các công đoàn bị kỳ thị ?
— TmTron

18

Dựa trên câu trả lời của Ryan, tôi phát hiện ra rằng ở đây không cần thêm bất kỳ chức năng nào. Chúng tôi có thể làm trực tiếp:

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("How did we get here?");
  }
}

Bạn có thể thấy nó hoạt động ở đây trong TS Playground


2
Tôi cũng thích sử dụng biến hơn thay vì tạo một hàm (mặc dù cả hai đều có thể bị loại bỏ tại thời điểm gói). Trong mọi trường hợp, Linter của bạn có thể phàn nàn về một biến không sử dụng, và bạn sẽ có thêm một cái gì đó như thế này ở trên nó:// eslint-disable-next-line @typescript-eslint/no-unused-vars
— Matthias

2
Hoặc, bạn có thể sử dụng biến trong lỗi bạn mắc phải, chẳng hạn như:throw new Error(`Unexpected: ${_exhaustiveCheck}`);
— Nooodles

1
Ý kiến ​​hay. Bạn cũng có thể xác định một hàm ẩn danh:default: ((x: never) => { throw new Error(c + " was unhandled."); })(c);
— Brady Holt

Tôi có thể xác nhận rằng giải pháp @Nooodles đáp ứng kiểm tra các biến không sử dụng SonarQube. Cảm ơn. Yêu làm thế nào ngắn gọn này là.
— Noumenon

Tôi đã giải quyết một số lỗi liên quan (tôi nghĩ rằng những lỗi này có thể xảy ra khá thường xuyên) từ đoạn mã trên như thế này:default: { const pleaseBeExhaustive: never = c; throw new Error(`Use all Color - ${pleaseBeExhaustive as string}`); }
— Jonny


5

Như một bước ngoặt hay cho câu trả lời của Ryan, bạn có thể thay thế neverbằng một chuỗi tùy ý để làm cho thông báo lỗi thân thiện hơn với người dùng.

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

Bây giờ, bạn nhận được:

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

Điều này hoạt động vì nevercó thể được gán cho bất kỳ thứ gì, kể cả một chuỗi tùy ý.


3

Dựa trên câu trả lời của Ryan và Carlos , bạn có thể sử dụng phương pháp ẩn danh để tránh phải tạo một hàm có tên riêng:

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);
  }
}

Nếu công tắc của bạn không đầy đủ, bạn sẽ gặp lỗi thời gian biên dịch .


2

Để tránh cảnh báo Typecript hoặc linter:

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

trong ngữ cảnh:

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

Sự khác biệt giữa giải pháp này và những giải pháp khác là

  • không có biến có tên không được tham chiếu
  • nó không ném ra một ngoại lệ vì Typecript sẽ thực thi rằng mã vẫn sẽ neverthực thi

Giải thích chi tiết về chức năng của mã và sự khác biệt của nó với các câu trả lời khác
— Sujitmohanty30

1

Trong những trường hợp thực sự đơn giản khi bạn chỉ cần trả về một số chuỗi theo giá trị enum, thì sẽ dễ dàng hơn (IMHO) sử dụng một số hằng để lưu trữ từ điển kết quả thay vì sử dụng switch. Ví dụ:

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] || ''
}

Vì vậy, ở đây bạn sẽ phải đề cập đến mọi giá trị enum trong hằng số, nếu không, bạn sẽ gặp lỗi như, ví dụ, nếu thiếu Blue:

TS2741: Thuộc tính 'Blue' bị thiếu trong loại '{[Color.Red]: string; [Màu.Green]: string; ' nhưng bắt buộc trong loại 'Bản ghi'.

Tuy nhiên, nó thường không phải như vậy và sau đó thực sự tốt hơn nếu bạn phạm lỗi giống như Ryan Cavanaugh đã đề xuất.

Ngoài ra, tôi hơi khó chịu khi thấy rằng điều này cũng không hoạt động:

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

Tạo một hàm tùy chỉnh thay vì sử dụng một switchcâu lệnh.

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

Ví dụ sử dụng

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

const v = 'a' as MyEnum;

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

Nếu sau này bạn thêm dvào MyEnum, bạn sẽ nhận được lỗiProperty 'd' is missing in type ...


Điều này chỉ hoạt động nếu giá trị enum của bạn là chuỗi vì chúng cần được mã hóa dưới dạng khóa trong tham số đối tượng map.
— Will Madden
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookie và Chính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.