Everyday TypeScript: Error Handling With Unions
Welcome to the Error Handling With Unions lesson!
This lesson is shown as static text below. However, it's designed to be used interactively. Click the button below to start!
We'll use a common type of JavaScript bug to explore error handling with unions. JavaScript has a
Numberfunction that converts strings like'1'into numbers like1.>
Number('1');Result:
1
When we give
Numbera string that isn't a number, it returnsNaN.>
Number('this is not a number');Result:
NaN
NaNis a special value in JavaScript that means "not a number". TypeScript follows JavaScript by allowingNaNvalues in variables of typenumber. That can allowNaNto sneak into code where we only expected actual numbers.Unexpected
NaNs will often flow through the system. For example:NaNplus anything isNaN,NaNminus anything isNaN, etc. TheNaNcan eventually cause a runtime error in a module far away from the original source of the bug. Or, worse, theNaNmight make it all the way into a database.>
NaN + 1;Result:
NaN
>
NaN * NaN;Result:
NaN
>
function square(n: number): number { return n * n; }square(NaN);Result:
NaN
Let's write
safeNumber, a safer version of theNumberconversion function that doesn't returnNaN.We could make
safeNumberthrow an exception when we try to convert a string that isn't a valid number. However, that's generally a bad idea for conversion functions likesafeNumber. When converting strings that come from users, we have to expect failure and handle it gracefully.Instead of throwing an exception, we can return a union. One side of the union indicates success: we converted the string to a number. The other side represents failure: the string wasn't actually a number. First, we'll define the
ConversionResultunion type:>
type ConversionSucceeded = {kind: 'success'value: number};type ConversionFailed = {kind: 'failure'reason: string};type ConversionResult = ConversionSucceeded | ConversionFailed;Internally,
safeNumberwill use the regularNumberfunction. It will also use the built-in JavaScript functionNumber.isNaNto check forNaNs. (We can't check for NaNs with=== NaNbecauseNaNdoesn't equal itself.)- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
function safeNumber(s: string): ConversionResult {const n: number = Number(s);if (Number.isNaN(n)) {return {kind: 'failure', reason: 'conversion returned a NaN'};} else {return {kind: 'success', value: n};}} - Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
safeNumber('1');Result:
{kind: 'success', value: 1} - Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
safeNumber('this is not a number');Result:
{kind: 'failure', reason: 'conversion returned a NaN'} Now we can see why our
safeNumberis safe. It returns an object, and objects can't be added to numbers. If we try to dosafeNumber(someString) + 1, it will be a type error. (You can typetype errorwhen a code example will fail with a type error.)- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
safeNumber('5') + 1;Result:
type error: Operator '+' cannot be applied to types 'ConversionResult' and 'number'.
The converted number is in the
valueproperty of the returned object. However, thatvalueproperty only exists on the success side of theConversionResultunion. If we try to dosafeNumber(someString).value, we'll get a type error.- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
// Think carefully about safeNumber's return type here.safeNumber('1').value;Result:
type error: Property 'value' does not exist on type 'ConversionResult'. Property 'value' does not exist on type 'ConversionFailed'.
This may seem wrong at first. We saw in an earlier example that
safeNumber('1')returns{kind: 'success', value: 1}. That object has avalueproperty, but when we tried to access.valuein the example above we got a type error. Why won't TypeScript let us access thevalueproperty that clearly exists?The answer lies in the distinction between types and runtime values. We only know that
safeNumber('1')has avalueproperty because we can actually call the function. But the TypeScript compiler can't call functions, so it can't know the exact value thatsafeNumber('1')will return. TypeScript only knows the types. Specifically, it knows thatsafeNumberreturns aConversionSucceeded | ConversionFailed, and that only one of those alternatives has avalueproperty.Here's another way to think about the same thing. Sometimes,
safeNumberreturns an object with novalue. If TypeScript allowed us to access.valueon the object, then we'd sometimes be accessing a property that doesn't exist, which would be a bug!Given that, how can we access
.value? We have to use anif, a ternary conditional (?and:), or aswitchto check the result. Did we get aConversionSucceeded(with avalue) or aConversionFailed(without avalue)?- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
const converted = safeNumber('5');converted.kind === 'success' ? converted.value : undefined;Result:
5
- Note: this code example reuses elements (variables, etc.) defined in earlier examples.
>
const converted = safeNumber('this is not a number');converted.kind === 'success' ? converted.value : undefined;Result:
undefined
This works because the ternary conditional is acting as a type guard. Inside the "true" branch of the conditional, the TypeScript compiler knows that
converted.kind === 'success'. That tells it thatconvertedis aConversionSucceeded, so it must have avalueattribute.By returning a union from
safeNumber, we've forced callers to deal with the error case. In the ternary above, we returnundefinedifsafeNumberfails. In a full application, we might show a "that wasn't a valid number" error to the user instead. The one thing that we can't do is accidentally continue on without handling the error; the type system guarantees that we won't make that mistake.