TypeScript. Advanced types

image



Hello Habitants! We have submitted another novelty

" Professional TypeScript. Development of scalable JavaScript-applications " to the printing house . In this book, programmers who are already intermediate in JavaScript will learn how to master TypeScript. You will see how TypeScript can help you scale your code up to 10x better and make programming fun again.



Below is an excerpt from a chapter from the book "Advanced Types".



Advanced types



The world-renowned TypeScript type system surprises even Haskell programmers with its capabilities. As you already know, it is not only expressive, but also easy to use: type restrictions and relationships in it are concise, understandable, and in most cases are deduced automatically.



Modeling dynamic JavaScript elements such as prototypes, bound by this, function overloads, and ever-changing objects requires a type system and their operators as rich as Batman would take.



I'll start this chapter with a deep dive into the topics of subtyping, compatibility, variance, random variable, and extension. Then I'll expand on the specifics of flow-based type checking, including refinement and totality. Next, I'll demonstrate some advanced programming features at the type level: connecting and mapping object types, using conditional types, defining type protections, and fallback solutions like type assertions and explicit assignment assertions. Finally, I'll introduce you to some advanced patterns for enhancing type safety: companion object patterning, interface enhancements for tuples, mimicking nominal types, and safe prototype extension.



Relationships between types



Let's take a closer look at relationships in TypeScript.



Subtypes and Supertypes



We've already touched on compatibility in the About Types section on p. 34, so let's dive right into this topic, starting with the definition of the subtype.

image




Return to fig. 3.1 and see TypeScript's built-in subtype associations.

image




  • Array is a subtype of an object.
  • A tuple is a subtype of an array.
  • Everything is a subtype of any.
  • never is a subtype of everything.
  • The Bird class, which extends the Animal class, is a subtype of the Animal class.


According to the definition I just gave for a subtype, this means that:



  • Wherever you need an object, you can use an array.
  • Wherever an array is needed, a tuple can be used.
  • Wherever you need any, you can use an object.
  • You can use never everywhere.
  • Wherever you need an Animal, you can use Bird.


A supertype is the opposite of a subtype.



SUPERTYPE



If you have two types, A and B, and B is a supertype of A, then you can safely use A wherever B is needed (Figure 6.2).


image


And again, based on the diagram in Fig. 3.1:



  • Array is a supertype of tuple.
  • Object is a supertype of array.
  • Any is a supertype of everything.
  • Never is not anyone's supertype.
  • Animal is a supertype of Bird.


It is just the opposite of subtypes and nothing more.



Variation



For most types, it is easy enough to understand whether a certain type A is a subtype of B. For simple types like number, string, etc., you can refer to the diagram in Fig. 3.1 or independently determine that the number contained in the union number | string is a subtype of this union.



But there are more complex types, such as generics. Consider these questions:



  • When is Array <A> a subtype of Array <B>?
  • When is Form A a subtype of Form B?
  • When is function (a: A) => B a subtype of function (c: C) => D?


Subtyping rules for types containing other types (that is, having type parameters like Array <A>, forms with fields like {a: number}, or functions like (a: A) => B) are harder to comprehend, because they are not consistent across different programming languages.



To make the following rules easier to read, I'll present a few syntax elements that don't work in TypeScript (don't worry, it's not mathematical):



  • A <: B means "A is a subtype of the same as type B";
  • A>: B means "A is a supertype of the same as type B".


Variation of Form and Array



To understand why languages ​​do not agree in the rules for subtyping complex types, an example with a form that describes a user in an application will help. We represent it through a couple of types:



//  ,   .
type ExistingUser = {
    id: number
   name: string
}
//  ,     .
type NewUser = {
   name: string
}


Suppose an intern at your company is tasked with writing code to delete a user. He begins with the following:



function deleteUser(user: {id?: number, name: string}) {
    delete user.id
}
let existingUser: ExistingUser = {
    id: 123456,
    name: 'Ima User'
}
deleteUser(existingUser)


deleteUser receives an object of type {id ?: number, name: string} and passes an existingUser of type {id: number, name: string} to it. Note that the type of the property id (number) is a subtype of the expected type (number | undefined). Therefore, the entire {id: number, name: string} object is a subtype of {id ?: number, name: string}, so TypeScript allows this.



Do you see any security issues? There is one: after passing ExistingUser to deleteUser, TypeScript does not know that the user id was deleted, so if you read existingUser.id after deleting it deleteUser (existingUser), then TypeScript will still assume that existingUser.id is of type number.



Obviously, using an object type where its supertype is expected is unsafe. So why does TypeScript allow this? The bottom line is that it was not meant to be completely safe. His type system seeks to catch real errors and make them visible to programmers of all levels. Since destructive updates (like deleting a property) are relatively rare in practice, TypeScript is relaxed and allows you to assign an object where its supertype is expected.



And what about the opposite case: is it possible to assign an object where its subtype is expected?



Let's add a new type for the old user, and then remove the user with that type (imagine adding types to the code your colleague wrote):



type LegacyUser = {
    id?: number | string
    name: string
}
let legacyUser: LegacyUser = {
    id: '793331',
    name: 'Xin Yang'
}
deleteUser(legacyUser) //  TS2345: a  'LegacyUser'
                                  //    
                                  // '{id?: number |undefined, name: string}'.
                                 //  'string'    'number |
                                 // undefined'.


When you submit a form with a property whose type is a supertype of the expected type, TypeScript swears. This is because id is a string | number | undefined and deleteUser only handles the case where id is number | undefined.



While expecting a form, you can pass a type with property types that are <: of the expected types, but you cannot pass a form without property types that are supertypes of their expected types. When we talk about types, we say, "TypeScript forms (objects and classes) are covariant in the types of their properties." That is, in order for object A to be assigned to object B, each of its properties must be <: the corresponding property in B.



Covariance is one of four types of variance:



Invariance

Specifically needed T.

Covariance

Needed <: T.

Contravariance

Needed>: T.

Bivariance Will

suit either <: T or>: T.



In TypeScript, every complex type is covariant in its members — objects, classes, arrays, and function return types — with one exception: function parameter types are contravariant.



. , . ( ). , Scala, Kotlin Flow, , .



TypeScript : , , , (, id deleteUser, , , ).


Variation of the function



Let's consider a few examples.



Function A is a subtype of function B if A has the same or less arity (number of parameters) than B, and:



  1. The type this, belonging to A, is either undefined, or>: of the type this, belonging to B.
  2. Each of the parameters A>: the corresponding parameter in B.
  3. Return type A <: return type B.


Note that in order for function A to be a subtype of function B, its this type and parameters must be>: counterparts in B, while its return type must be <:. Why does the condition reversal? Why doesn't the simple <: condition work for each component (of type this, parameter types, and return type), as is the case with objects, arrays, unions, etc.?



Let's start by defining three types (instead of class, you can use other types, where A: <B <: C):



class Animal {}
class Bird extends Animal {
    chirp() {}
}
class Crow extends Bird {
    caw() {}
}


So Crow <: Bird <: Animal.



Let's define a function that takes Bird and makes it tweet:



function chirp(bird: Bird): Bird {
    bird.chirp()
    return bird
}


So far so good. What does TypeScript allow you to pipe to chirp?



chirp(new Animal) //  TS2345:   'Animal'
chirp(new Bird) //     'Bird'.
chirp(new Crow)


A Bird instance (as a chirp parameter of type bird) or a Crow instance (as a subtype of Bird). Subtype passing works as expected.



Let's create a new function. This time its parameter will be a function:



function clone(f: (b: Bird) => Bird): void {
    // ...
}


clone requires a function f that receives Bird and returns Bird. What types of functions can be passed to f safely? Obviously, the function that receives and returns Bird:



function birdToBird(b: Bird): Bird {
    // ...
}
clone(birdToBird) // OK


What about a function that takes a Bird but returns a Crow or Animal?



function birdToCrow(d: Bird): Crow {
    // ...
}
clone(birdToCrow) // OK
function birdToAnimal(d: Bird): Animal {
    // ...
}
clone(birdToAnimal) //  TS2345:   '(d: Bird) =>
                             // Animal'    
                            // '(b: Bird) => Bird'. 'Animal'
                           //    'Bird'.


birdToCrow works as expected, but birdToAnimal throws an error. Why? Imagine a clone implementation looks like this:



function clone(f: (b: Bird) => Bird): void {
    let parent = new Bird
    let babyBird = f(parent)
    babyBird.chirp()
}


By passing the function f to clone, which returns Animal, we cannot call .chirp in it. Therefore, TypeScript must make sure that the function we pass in returns at least Bird.



When we say that functions are covariant in their return types, it means that a function can be a subtype of another function only if its return type is <: the return type of that function.



Okay, so what about parameter types?



function animalToBird(a: Animal): Bird {
  // ...
}
clone(animalToBird) // OK
function crowToBird(c: Crow): Bird {
  // ...
}
clone(crowToBird)        //  TS2345:   '(c: Crow) =>
                        // Bird'     '
                       // (b: Bird) => Bird'.


For a function to be compatible with another function, all its parameter types (including this) must be>: their corresponding parameters in the other function. To understand why, think about how the user might implement crowToBird before passing it to clone?



function crowToBird(c: Crow): Bird {
  c.caw()
  return new Bird
}


TSC-: STRICTFUNCTIONTYPES



- TypeScript this. , , {«strictFunctionTypes»: true} tsconfig.json.



{«strict»: true}, .


Now, if clone calls crowToBird with new Bird, we get an exception, because .caw is defined in all Crow, but not all Birds.



This means that functions are contravariant in their parameters and this types. That is, a function can be a subtype of another function only if each of its parameters and type this are>: their corresponding parameters in the other function.



Fortunately, these rules don't need to be memorized. Just remember them when the editor gives a red underline when you pass an incorrectly typed function somewhere.



Compatibility



Subtype and supertype relationships are a key concept in any statically typed language. They are also important for understanding how compatibility works (remember, compatibility refers to the TypeScript rules governing the use of type A where type B is required).



When TypeScript needs to answer the question, "Is type A compatible with type B?", It follows simple rules. For non-enum types — such as arrays, booleans, numbers, objects, functions, classes, class instances, and strings, including literal types — A is compatible with B if one of the conditions is true.



  1. A <: B.
  2. A is any.


Rule 1 is just a subtype definition: if A is a subtype of B, then wherever B is needed, you can use A.



Rule 2 is an exception to Rule 1 for ease of interacting with JavaScript code.

For enumeration types created by the keywords enum or const enum, type A is compatible with enumeration B if one of the conditions is true.



  1. A is a member of enumeration B.
  2. B has at least one member of type number, and A is number.


Rule 1 is exactly the same as for simple types (if A is a member of B, then A is of type B and we say B <: B).



Rule 2 is necessary for the convenience of working with enumerations, which seriously compromise the security of TypeScript (see the subsection “Enum” on page 60), and I recommend avoiding them.



Type expansion Type



expansion is the key to understanding how type inference works. TypeScript is lenient in execution and is more likely to err in deducing more general type than deducing as specific as possible. This will make your life easier and reduce the time it takes to deal with the type checker notes.



You've already seen several examples of type expansion in Chapter 3. Consider others.



When you declare a variable as mutable (with let or var), its type expands from the value type of its literal to the base type to which the literal belongs:



let a = 'x' // string
let b = 3   // number
var c = true   // boolean
const d = {x: 3}   // {x: number}
enum E {X, Y, Z}
let e = E.X   // E


This does not apply to immutable declarations:



const a = 'x' // 'x'
const b = 3   // 3
const c = true   // true
enum E {X, Y, Z}
const e = E.X   // E.X


You can use explicit type annotation to prevent it from expanding:



let a: 'x' = 'x' // 'x'
let b: 3 = 3  // 3
var c: true = true  // true
const d: {x: 3} = {x: 3}  // {x: 3}


When you re-assign a non-extended type with let or var, TypeScript extends it for you. To prevent this, add an explicit type annotation to the original declaration:



const a = 'x' // 'x'
let b = a  // string
const c: 'x' = 'x'  // 'x'
let d = c  // 'x'


Variables initialized to null or undefined expand to any:



let a = null // any
a = 3  // any
a = 'b'  // any


But, when a variable, initialized to null or undefined, leaves the scope in which it was declared, TypeScript assigns it a specific type:



function x() {
   let a = null  // any
   a = 3   // any
   a = 'b'   // any
   return a
}
x()   // string


The const



type The const type helps to avoid extending the type declaration. Use it as a type assertion (see the subsection "Type approvals" on page 185):



let a = {x: 3}   // {x: number}
let b: {x: 3}    // {x: 3}
let c = {x: 3} as const   // {readonly x: 3}


const eliminates type expansion and recursively marks its members as readonly, even in deeply nested data structures:



let d = [1, {x: 2}]              // (number | {x: number})[]
let e = [1, {x: 2}] as const    // readonly [1, {readonly x: 2}]


Use as const when you want TypeScript to deduce as narrow as possible.



Checking For Extra Properties



Type expansion also comes into play when TypeScript checks whether one type of object is compatible with another type of object.



Object types are covariant in their members (see the “Shape and Array Variation” subsection on page 148). But, if TypeScript follows this rule without additional checks, problems can arise.



For example, consider an Options object that you can pass to a class to customize it:



type Options = {
    baseURL: string
    cacheSize?: number
    tier?: 'prod' | 'dev'
}
class API {
    constructor(private options: Options) {}
}
new API({
     baseURL: 'https://api.mysite.com',
     tier: 'prod'
})


What happens now if you make a mistake in the option?



new API({
   baseURL: 'https://api.mysite.com',
   tierr: 'prod'         //  TS2345:   '{tierr: string}'
})                      //     'Options'.
                        //     
                       //  ,  'tierr'  
                      //   'Options'.    'tier'?


This is a common JavaScript bug, and it's good that TypeScript helps you catch it. But if the types of objects are covariant in their members, how does TypeScript intercept it?



In other words:



  • We expected the type {baseURL: string, cacheSize ?: number, tier ?: 'prod' | 'dev'}.
  • We passed the type {baseURL: string, tierr: string}.
  • The type passed is a subtype of the expected type, but TypeScript knew to report an error.


By checking for extra properties , when you try to assign a new object literal type T to another type, U, and T has properties that U does not have, TypeScript reports an error.



The new object literal type is the type that TypeScript inferred from an object literal. If this object literal uses a type assertion (see the subsection “Type Assertions” on page 185) or is assigned to a variable, then the new type is expanded to the regular object type and its novelty is lost.



Let's try to make this definition more capacious:



type Options = {
     baseURL: string
     cacheSize?: number
     tier?: 'prod' | 'dev'
}
class API {
    constructor(private options: Options) {}
}
new API({ ❶
    baseURL: 'https://api.mysite.com',
    tier: 'prod'
})
new API({ ❷
    baseURL: 'https://api.mysite.com',
    badTier: 'prod' //  TS2345:   '{baseURL:
}) // string; badTier: string}' 
//    'Options'.
new API({ ❸
    baseURL: 'https://api.mysite.com',
    badTier: 'prod'
} as Options)
let badOptions = { ❹
    baseURL: 'https://api.mysite.com',
    badTier: 'prod'
}
new API(badOptions)
let options: Options = { ❺
    baseURL: 'https://api.mysite.com',
    badTier: 'prod' //  TS2322:  '{baseURL: string;
} // badTier: string}'  
// 'Options'.
new API(options)


❶ Instantiate the API with baseURL and one of two optional properties: tier. Everything is working.



❷ We mistakenly prescribe tier as badTier. The options object that we pass to the new API is new (its type is inferred, it is incompatible with the variable, and we do not type assertions for it), so when checking unnecessary properties, TypeScript detects an extra badTier property (which is defined in the options object, but not in type Options).



❸ Make a statement that the invalid options object is of type Options. TypeScript no longer considers it new and concludes from checking for extra properties that there are no errors. The as T syntax is described in the "Type Assertions" section on p. 185.



❹ Assigning the options object to the badOptions variable. TypeScript no longer perceives it as new and, after checking for unnecessary properties, concludes that there are no errors.



❺ When we explicitly type options as Options, the object we assign to options is new, so TypeScript checks for extra properties and finds a bug. Note that in this case the check for extra properties is not done when we pass options to the new API, but it does when we try to assign an options object to the options variable.



You don't need to memorize these rules. This is just an internal TypeScript heuristic to catch as many bugs as possible. Just keep them in mind if you suddenly wonder how TypeScript found out about a bug that even Ivan - an old-timer of your company and also a professional code censor - did not notice.



The



TypeScript refinement performs symbolic execution of type inference. The type checking module uses command flow instructions (like if,?, ||, and switch) along with type queries (like typeof, instanceof, and in), thereby qualifying the types as a programmer reads through the code. However, this handy feature is supported in very few languages.



Imagine you've developed an API for defining CSS rules in TypeScript, and your colleague wants to use it to set the width of an HTML element. It passes the width you want to parse and check later.



First, let's implement a function to parse a CSS string into value and unit:



//       
//  ,      CSS
type Unit = 'cm' | 'px' | '%'
//   
let units: Unit[] = ['cm', 'px', '%']
//   . .   null,    
function parseUnit(value: string): Unit | null {
  for (let i = 0; i < units.length; i++) {
    if (value.endsWith(units[i])) {
       return units[i]
}
}
     return null
}


We then use parseUnit to parse the user-supplied width. width can be a number (perhaps in pixels), or a string with the units attached, or null, or undefined.



In this example, we use type qualification several times:



type Width = {
     unit: Unit,
     value: number
}
function parseWidth(width: number | string | null |
undefined): Width | null {
//  width — null  undefined,  .
if (width == null) { ❶
     return null
}
//  width — number,  .
if (typeof width === 'number') { ❷
    return {unit: 'px', value: width}
}
//      width.
let unit = parseUnit(width)
if (unit) { ❸
return {unit, value: parseFloat(width)}
}
//     null.
return null
}


❶ TypeScript is able to understand that JavaScript's loose equality against null will return true for both null and undefined. He also knows that if the check passes, then we will make a return, and if we do not do a return, then the check failed and from that moment on, the width type is number | string (it can no longer be null or undefined). We say that the type was refined from number | string | null | undefined in number | string.



❷ The typeof check asks for a value at runtime to see its type. TypeScript also takes advantage of typeof at compile time: in the if branch where the test passes, TypeScript knows that width is number. Otherwise (if this branch does return) width should be string - the only remaining type.



❸ Since parseUnit can return null, we check this. TypeScript knows that if unit is correct then it must be of type Unit in the if branch. Otherwise, unit is incorrect, which means its type is null (refined from Unit | null).



❹ Finally, we return null. This can only happen if the user passes in a string for width, but that string contains unsupported units.

I went through TypeScript's train of thought for each type refinement that was made. TypeScript does a great job of taking your reasoning as you read and write code and crystallizing it into type checking and inference order.



Discriminated join types



As we just found out, TypeScript has a good understanding of how JavaScript works and is able to track our type qualifications as if reading our minds.



Let's say we are creating a custom event system for an application. We start by defining event types along with the functions that handle the arrival of those events. Imagine that UserTextEvent simulates a keyboard event (for example, the user typed <input />), and UserMouseEvent simulates a mouse event (the user moved the mouse to coordinates [100, 200]):



type UserTextEvent = {value: string}
type UserMouseEvent = {value: [number, number]}
type UserEvent = UserTextEvent | UserMouseEvent
function handle(event: UserEvent) {
     if (typeof event.value === 'string') {
         event.value // string
         // ...
         return
   }
         event.value // [number, number]
}


TypeScript knows that inside the if block, event.value must be a string (thanks to the typeof check), that is, event.value after the if block must be a [number, number] tuple (because of the return in the if block).



What will complication lead to? Let's add clarifications to the event types:



type UserTextEvent = {value: string, target: HTMLInputElement}
type UserMouseEvent = {value: [number, number], target: HTMLElement}
type UserEvent = UserTextEvent | UserMouseEvent
function handle(event: UserEvent) {
    if (typeof event.value === 'string') {
        event.value // string
        event.target // HTMLInputElement | HTMLElement (!!!)
        // ...
        return
   }
  event.value // [number, number]
  event.target // HTMLInputElement | HTMLElement (!!!)
}


While the refinement worked for event.value, it did not for event.target. Why? When handle receives a parameter of type UserEvent, this does not mean that you need to pass it either UserTextEvent or UserMouseEvent - in fact, you can pass an argument of type UserMouseEvent | UserTextEvent. And since the members of a union can overlap, TypeScript needs a more reliable way to know when and which case of a union is relevant.



You can do this by using literal types and a tag definition for each case of union type. Nice tag:



  • In each case, it is located at the same place of the union type. Implies the same object field when it comes to combining object types, or the same index when it comes to combining tuples. In practice, discriminated unions are more often objects.
  • Typed as a literal type (string literal, numeric, boolean, etc.). You can mix and match different types of literals, but it's best to stick to a single type. Typically this is a type of string literal.
  • Not universal. Tags must not receive generic type arguments.
  • Mutually exclusive (unique within the union type).


Let's update the types of events taking into account the above:



type UserTextEvent = {type: 'TextEvent', value: string,
                                        target: HTMLInputElement}
type UserMouseEvent = {type: 'MouseEvent', value: [number, number],
                                        target: HTMLElement}
type UserEvent = UserTextEvent | UserMouseEvent
function handle(event: UserEvent) {
   if (event.type === 'TextEvent') {
       event.value // string
       event.target // HTMLInputElement
       // ...
       return
   }
  event.value // [number, number]
  event.target // HTMLElement
}


Now, when we refine event based on the value of its tagged field (event.type), TypeScript knows that there should be a UserTextEvent in the if branch, and after the if branch, it should have a UserMouseEvent.Because tags are unique in each union type, TypeScript knows that they are mutually exclusive.



Use discriminated joins when writing a function that handles various cases of join type. For example, when working with Flux actions, redux restores, or useReducer in React.



You can familiarize yourself with the book in more detail and pre-order at a special price on the publisher's website



All Articles