Skip to content

Instantly share code, notes, and snippets.

@joshuabowers
Created January 11, 2024 01:31
Show Gist options
  • Save joshuabowers/4e5ce07a3b36bb58ce869f4e3958a891 to your computer and use it in GitHub Desktop.
Save joshuabowers/4e5ce07a3b36bb58ce869f4e3958a891 to your computer and use it in GitHub Desktop.
typefn: 007 - Type Guards

A common pattern in code which needs to uniquely handle different types of data in a (relatively) uniform fashion is to branch and cast for each type to perform the type-specific implementation. This can be slightly cumbersome in some languages, but TypeScript provides a convenient way to combine these steps in a fairly intuitive fashion.

Notably, whenever a branch occurs on data that is of potentially multiple types, as the conditions on the branches winnow the knowledge of what type the data can be, TypeScript will automatically treat the data as belonging to the winnowed types in the appropriate branches. That is, this type narrowing will cast the broader type to shrink to a subset of its definition as TypeScript determines that doing so is safe.

One can imagine, for example, a variable that is typed to allow either numeric or textual data: let data: number|string. When TypeScript encounters data, it knows that it can be one of two types, either number or string. However, should a branch conditional determine that the contents of the variable is specifically numeric, TypeScript intuits that, within the context of that branch being true, the variable can safely be assumed numeric. Furthermore, this narrows the types for the other branches: in this case, trivially causing the false branch to treat the variable as though it were string. For more complicated types, the type will be narrowed with each successive failed branch conditional.

TypeScript is not limited to performing type narrowing specifically in the context of an inline type check in a branch conditional. Rather, the idea can be abstracted out to special predicate functions called type guards, which perform boolean operations to ascertain the type information of the data they are given, and then make, based off those operations, an assertion about the resulting type.

A type guard has a special return type: parameter is Type; that is, the return type mentions the parameter they guard is type asserting, utilizes the is keyword to signal the type assertion, then provides the type (either inline or predefined) being asserted. TypeScript will then cause true branches downstream of the guard invocation to automatically treat the guard argument to be narrowed.

Guards need only return a boolean value; when transpiled down to JavaScript, they function as standard boolean predicates. These functions do require a bit of trust: one could, for example, write a guard which asserts that everything passed to it is valid, even when this is untrue. As with all programmed systems, having ample test suites which verify the integrity and validity of type guards is very desirable.

The predicate function for a type guard may use whatever criteria it wants to assert truth, just so long as the result is a boolean value. This can include using the typeof operator to interrogate a variable for its JavaScript prototype type information, using the strict equality operator (===) to perform a comparison to a known value, checking to see if a field exists within an object's properties via the in keyword, and so forth.

As type guards are predicates, and predicates are functions, one can even generate new type guards that conform to well defined behavioral checks using higher order functions, generics, and closures. In such a system, the HOF would take a value or conditional function to use in the generated guard, as well as the type to assert in the guard's return type. The generated function can then use this enclosed scope to perform checks on whatever parameter is passed to it.

// Some basic type definitions. Both Circle and Square explicitly
// augment Shape with their own type-specific data; while asserting
// they are shapes isn't necessary here, this would allow Shape to
// be extended with more fields---such as `location` or `color`---
// which the derived types would automatically gain.
type Shape = {kind: string}
type Circle = Shape & {kind: 'circle', radius: number}
type Square = Shape & {kind: 'square', side: number}
// A basic object type guard: given an explicitly unknown value,
// ascertain if it is an object and non-null. Note that empty
// objects (e.g., `{}`) will pass this with `true`.
// When transpiled, this will result in a simple boolean check;
// in the context of a TypeScript runtime, however, the return
// type asserts that the parameter is of the specific type,
// allowing TypeScript to automatically cast it in true contexts.
const isPresentObject = (value: unknown): value is Object =>
typeof value === 'object' && !!value
// A slightly more specific type guard, asserting that the parameter
// conforms to the type definition for `Shape`. In delegating some
// of the behavior to `isPresentObject`, `value`, after that call,
// is guaranteed to be a non-null object, and a direct field check of
// its properties is possible.
const isShape = (value: unknown): value is Shape =>
isPresentObject(value) && ('kind' in value);
// A HOF for generating derivative type guards. If `kind` acts as a
// good enough determinant for a given derived type's structure and
// behavior, this sort of formulation is sufficient. (Ideally, all
// fields in the derived types would be checked for, but that's not
// always necessary.) This uses Generics, HOFs, and Closures to accomplish
// these derived type guards.
const isSpecificShape = <T extends Shape>(kind: string) =>
(value: unknown): value is T =>
isShape(value) && value.kind === kind;
// Define a couple of derived type guards using the above HOF. These will
// both assert that a given value that is passed to them conforms to
// the supplied generic parameter---as far as `isSpecificShape` is concerned.
// The first function has type (value: unknown) => value is Circle, while
// the second function has type (value: unknown) => value is Square
const isCircle = isSpecificShape<Circle>('circle');
const isSquare = isSpecificShape<Square>('square');
// Define a couple of simple object creation functions for the derived types.
const circle = (radius: number): Circle => ({kind: 'circle', radius});
const square = (side: number): Square => ({kind: 'square', side});
// Create a few different shapes that can be later manipulated.
const shapes: Shape[] = [
circle(3),
square(10),
circle(9)
];
// Use the above type guards to ascertain the type of shape that is being
// considered, and calculate the area of the shape accordingly. Recall that
// type guards, in asserting the type they are verifying, will cause TypeScript
// to automatically cast the argument in true-branches to the asserted type,
// thus making the field referencing safe-ish.
const areas = shapes.map( shape =>
isCircle(shape)
? Math.PI * Math.pow(shape.radius, 2)
: (isSquare(shape) ? Math.pow(shape.side, 2) : NaN)
);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment