神刀安全网

From JavaScript to TypeScript, Pt. IV: Generics & Algebraic Data Types

They don’t get as much love as classes, but TypeScript’s generics and fancier type features are some of its most powerful design features of the language.

If you’ve got a background in Java, you’ll be familiar with how much of a splash they made when they first came around. Similarly, if you’ve written any Elm , you’ll be right at home with how TypeScript expresses algebraic data types.

This article will cover:

  • Union & Intersection Types;
  • Type Aliases & Type Guards; and
  • Generics.

I expect you’re using Node to run the code samples; I’m running v6.0.0. To get the code, clone the repo and checkout the Part_4 branch:

git clone https://github.com/Peleke/oop_in_typescript git checkout Part_4_Generics_and_More_Types

Running tsc will create a directory called built , in which you’ll find the compiled JavaScript. The compiler will spit out a window’s worth of errors, but you can safely ignore them.

Union Types

TypeScript lets us declare that a variable is a string , number , or anything else. It also lets us create a list of types, and declare that a variable is allowed to have any of the types in that list. You do this with a union type .

A union type is somewhere between a specific type and any . A union type gives a list of types that that a value can have .

A variable with a union type (A | B) can hold a value of type A , or of type B . The pipe character tells TypeScript we should be happy whichever one we get.

The "Hello, world" of union types is a function that accepts either a string or a number:

// basic_union.ts  "use strict";  // foo can print either numbers or strings without issue. function foo (bar : (string | number)) : void {     console.log(`foo says ${bar}.`); }  foo('Hello, world'); foo(42);

Instead of writing (string | number) everywhere, we can rename it with a type alias :

// basic_unions_alias.ts  "use strict";  // Parentheses aren't required in type alias definitions. type Alphanumeric = string | number;  function foo (bar : Alphanumeric) : void {     console.log(`foo says ${bar}.`); }  foo('Hello, world'); foo(42);

That’s all there is to it.

One important caveat is that you can’t use type aliases recursively. That means you can’t do things like this:

"use strict";  type Tree = Empty | Leaf | Tree; // This is invalid . . .  type Alphanumeric = string | number;  type Empty = void; type Leaf = Alphanumeric; type TreeNode = [Tree, Tree] // . . . And this is, too.

This is a typical way to define recursive data structures in certain functional programming languages, but aliases and unions aren’t as robust in TypeScript.

Intersection Types

Intersections are essentially the opposite of unions. Whereas a union type can hold any of the types in its list, an intersection type can only hold a value that is simultaneously an instance of every type in its list. You define them like union types, but replace the pipe with an & .

"use strict";  // Any object of type Enjoyable must have both a  //   'drink' and a 'smoke' method. type Enjoyable = Drinkable & Smokable;  interface Drinkable {     drink : (() => void); }  interface Smokable {     smoke : (() => void); }

While uncommon, interfaces are powerful tools for guaranteeing that an object implements some set of interfaces. TypeScript’s inference is pretty good most of the time, but sometimes you want a rock-solid check. When that happens, you’re stuck running a series of property checks. Intersections cleans all that right up.

In the skeleton below, handleRefresh manages common cacheing and view update logic for Updatabale & Cacheable widgets.

I could also type it to accept a Widget , but then I wouldn’t be able to use Updatable & Cacheable objects from arbitrary hierarchies. That doesn’t matter right now, but when I change things down the line — which I inevitably will — the flexibility will make the code much less brittle.

// intersection.ts  "use strict";  type DynamicWidget = Updatable & Cacheable;  function handleRefresh (widget : DynamicWidget) : void {   // Update widget's cache, and inform listening views that it's time to    //   update the UI. }  // Interfaces // =============================================== interface Updatable {    // SettingsWidget changes user settings; VocabularyWidget fetches new    //   translations.   update : (() => void); }  interface Cacheable {    // This is an example of where interfaces shine. Both widgets below are   //   cacheable, but each takes a totally different approach to caching:   //   VocabularyWidget does so on a session basis, whereas   //   SettingsWidget saves user preferences to local storage   cache : (() => void)  }  // Utility Class // =============================================== class User { /* Omitted for brevity */  }  // Widget classes // =============================================== abstract class Widget { /* Omitted for brevity */ }  class VocabularyWidget extends Widget implements Updatable, Cacheable {    constructor(private user : User, private words : String[]) { super() }    cache () : void {     // Cache API responses . . .   }    /* Remainder omitted */  }  class SettingsWidget extends Widget implements Updatable, Cacheable {    constructor(private user : User) { super() }    cache () : void {     // Save user preferences to local storage . ..     }    /* Remainder omitted */ }

While I happened to use it here, intersection types are generally rare. I usually use them as a form of documentation. Otherwise, there are few things you can do with an intersection type that you can’t accomplish through some other, equally readable means.

Type Guards

What if handleRefresh took a union type of Cacheable or Updatable , and executed different logic depending on which it got?

Well, to be frank, I’d probably rewrite the code. But sometimes it’s convenient to write a function that accepts a union and branches based on which type it actually receives.

TypeScript has a handy tool for this sutation, called the type guard . A type guard is a functino that makes sure a value has the properties you’d expect of that type.

If it does, it confirms that the value is of the tested type. If it doesn’t, it confirms that the value is not of the tested type, and so has to be of one of the others. TypeScript uses that extra information to simplify conditional statements.

User-Defined Type Guards

A type guard is just a function that accepts a union type over the types you need to check for.

// type_guards.ts  "use strict";  function handleRefresh(widget : (Updatable | Refreshable)) : void {     if (isRefreshable(widget)) {         // widget.update();         // publish update notifications . . .      }     else {          // widget.cache();         // handle caching logic . . .      } }  function isRefreshable(widget : (Refreshable | Cacheable)) : widget is Refreshable {     // Cast widget to Refreshable; check for defining property.     return (<Refreshable> widget).update !== undefined; }  function isCacheable(widget : (Refreshable | Cacheable)) : widget is Cacheable {     // Cast widget to Cacheable; check for defining property.     return (<Cacheable> widget).cache !== undefined; }

Only two things are new:

  1. Its return type is a type predicate .
  2. You must cast the argument to the type that you’d expect to have the property you’re checking for.

Type Predicates

Think of a type predicate as a special boolean value for the type system. Notice that we can just use else , rather than else if (isCacheable(widget)) , and still successfully access the cache property. This is because TypeScript knows that, if our widget isn’t Refreshable , it must be Cacheable . TypeScript is able to learn this from the type predicate, but wouldn’t be able to simpliy things that much if we’d just returned true or false .

Typecasting

Casting the widget to the type we’re testing against forces TypeScript to allow the property access. If we don’t do the cast, it treats the value as an element of the union type you passed in — here, (Updatable | Cacheable) — which won’t have the property you’re looking for. This causes an error, which we eliminate with the cast.

We could have written this example differently:

function handleRefresh(widget : (Updatable | Refreshable)) : void {     if ((<Refreshable> widget).refresh !== undefined) {         // widget.update();         // publish update notifications . . .      }     else if ((<Cacheable> widget).cache !== undefined) {          // widget.cache();         // handle caching logic . . .      } }

. . . But that would be brittle and verbose. And no one likes brittle code . . . Well, except for some people .

Primitive Guards

If you’re checking against the values of a union type over certain primitives, you don’t have to write a type guard like this. You can just use the typeof functino directly.

// primitive_type_guards.ts  "use strict";  type Alphanumeric = string | number;  function echoType (message : Alphanumeric) : void {     if (typeof message === "string")         console.log(`You sent me a message: '${message}'!`);     else         console.log(`You sent me your number: ${message}!`); }  echoType("You're amazing"); // "You sent me a message: 'You're amazing'!" echoType(42);

You can use typeof like this with four primitive types:

  1. String;
  2. Number;
  3. Boolean; and
  4. Symbol .

A useful related idiom is the string literal type , which allows you to define a type that must have one of a given set of string values. The official example for this is excellent:

"use strict";  // Define a series of string constants type Easing = "ease-in" | "ease-out" | "ease-in-out";  class UIElement {     animate(dx: number, dy: number, easing: Easing) {     // Use typeguards to branch through options . . .          if (easing === "ease-in") {             // ...         }         else if (easing === "ease-out") {         }         else if (easing === "ease-in-out") {         }         else {         // . . . Or throw, if the user passed a malformed value.             throw new Error(`Error: ${easing} is not allowed here.`);         }     } }

Generics

Unions let us write more general code than specific types do. There are a few fundamental shortcomings with using unions to describe general types, though.

  1. You have to know the types beforehand to define the union properly.
  2. Writing typeguards and conditional logic is brittle and verbose.

These both prevent union types from being a sufficient solution to problems requiring full generality. They’re general, but only in a very specific way.

One solution is to use any . But this throws away type checking entirely.

What if we want to create a specialized collection data type specifically for children of one of our base classes? Defining the collection in terms of any works , in that we’ll be able to stuff those objects into the collection, but we’d have to check that each object comes from the right classes ourselves.

TypeScript’s solution to the problem is generics . Generics allow you to define functions, classes, and interfaces that work in general, while preserving some of the typechecks that any throws away.

Simple Generics

Let’s see what this looks like.

// simple_generic.ts  "use strict";  // The general formula is: // FUNCTION_NAME < TYPE_VARIABLE> (args) : RETURN_TYPE { FUNCTION_BODY } // It's the same thing you're used to, plus the <TYPE_VARIABLE> bit. function identity1<T>(input : T) : T {   // This works, because we can return "input" no matter what it is.   return input; }  // We could have used any type variable; there's nothing special about T. function identity2<E>(input : E) : string {   // This works, because everything in JavaScript can call    //   Object.prototype.toString.   return input.toString(); }  // The variable T means, "single element with some random type." //   It can't be, "literally any value, including maybe an array, map, or set." // function getMixedContents1<T>(input : T) : T { //   let contents : T[] = []; //  //   // This doesn't work. Only Array has a forEach. What if we're passed a string? //   input.forEach((val) => contents.push(val)) //  //   return contents; // }  function getMixedContents2<T>(input : T[]) : T[] {   let contents : T[] = [];    // This works, because all arrays have a forEach.   input.forEach((val) => contents.push(val))    return contents; }

To use a generic type within a function, define your function as before, and add <TYPE_VARIABLE> right before the arguments list. Once you’ve done that, you can use TYPE_VARIABLE throughout the signature or function body as you would any other type.

The variable T is traditional — it stands for T ype — but you can use anything, s long as you’re consistent. Some people use E for elements in a collection, for instance.

Unwrapping the identity Functions

Note that identity1 expects an argument of type T , and returns a variable of type T . There’s no reason we have to return the same thing we accept, though: identity2 takes a T and returns a string .

identity2 is a good example of generic type checking at work. The code compiles, but not before TypeScript checks to make sure input has a toString property. Our generic could be anything, so we’re not allowed to access properties we can’t guarantee we’ll find.

But every object in JavaScript delegates a toString property lookup to Object.prototype . This is how TypeScript satisfies itself that the toString call won’t throw at runtime.

Generic Arrays

getMixedContents1 won’t compile, because it calls forEach on a generic input .

We can do this with an array, but not in general: If someone passes a string, it won’t have forEach .

The problem is easy to fix, though: Just give input a T[] type. Again, you can use T like any other type — either as an indidual element, or as a constituent of a collection.

This is an important point: Generics are a sort of "anything" value, but they’re individual anything values . A generic can’t be a thing or a list of things.

These are good examples of the difference between generics and any . You can do whatever you want to an any type — TypeScript won’t check property access or method calls, because it assumes you know what you’re doing. On the other hand, using a generic ensures TypeScript will prevent you from doing anything that isn’t valid on every possible input. This way, you can be sure your code actually is as generic as you intend!

Generic Interfaces

TypeScript also allows you to use generics when defining method signatures and data members in an interface.

Let’s say each widget I have on a page has an associated DataManager , which handles caching and refreshes. We can bundle those methods into an interface exposing two methods: cache and refresh .

cache will either receive data to cache, or already have access to it, depending on whose DataManager we’re dealing with. cache should take a generic argument, so it can receive any sort of data at all.

// generic.ts  "use strict";  interface DataManager {   cache<E>( items? : E[] ) : void;   refresh : (() => void); }

Note that:

  1. Defining generics is exactly the same here: Function name, followed by brackets containing a type variable.
  2. The question mark after items indicates that it’s an optional argument. Implementers can ignore it if they want.

The syntax for generics takes a bit of getting used to, but there’s nothing surprising about how this code is written otherwise.

Alternatively, we could write:

interface DataManager<E> {   cache( items? : E[] ) : void;   refresh : (() => void); }

In the previous example, only cache knew about E . This syntax allows the entire interface to use E . This makes sense if you use the type variable in several method signatures or data members.

In our case, though, it’s better to annotate cache directly; it’s more precise.

Generic Classes

As with interfaces, you simply follow the class name with brackets containing a type variable to use generics in classes.

Here, I have a View class that accepts a generic value, and assigns it as its _manager . We use the generic because we don’t necessarily care if the manager formally implements the interface — we allow it as long as it has the right methods.

// generic.ts  "use strict";  class View<T> {    constructor (private _manager : T) { }    get manager () : T {     return this._manager;   }  }

This works, but allowing anything to come through as a DataManager defeats the purpose of typing.

Instead, we can use a generic constraint specify that we’ll alow any type, as long as it implements DataManager .

// generic.ts  "use strict";  // The "extends" clause is our constraint. No, you can't use "implements."  //    It must be "extends", even though we're referring to an interface. class View<T extends DataManager> {    constructor (private _manager : T) { }    get manager () : T {     return this._manager;   }  }

If you remember the rules for structural typing, you’ll realize that we could avoid generics altogether:

class View {    constructor (private _manager : DataManager) { }    get manager () : DataManager {     return this._manager;   } }

Either way is acceptable, but I prefer to be explicit. I usually do less debugging that way!

Conclusion

Basic types get the job done most of the time, but some things are easier with a bit more generality. Building data structures and application architectures are common examples.

The docs on generics and advanced types cover a few more esoteric use cases, though I’ve only ever found occasion to actually use what I’ve covered here.

As always, feel free to leave questions or comments below, or hit me on Twitter ( @PelekeS ) — I’ll get back to everyone indiidually!gg

转载本站任何文章请注明:转载至神刀安全网,谢谢神刀安全网 » From JavaScript to TypeScript, Pt. IV: Generics & Algebraic Data Types

分享到:更多 ()

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址