?
Digital Application Development
9 minute read

TypeScript Records and Mapped Types

Let's take a tour of TypeScript's Record and Mapped Type features by looking at their usage and evolution within a project.

In This Article

Learning TypeScript from its documentation can be a little alienating. Lots of language facilities are presented, but how they work together is not immediately obvious, especially if (like me) you've been working in dynamically typed languages for the last four years. 

This article will quickly mix together several features to achieve the result we needed. In doing so, it'll help you figure out how to do similar types of combinations.

copy link

We begin with a record type

We start this story with the Product interface — it's pre-existing in the codebase. This is not a complicated type at all — a bunch of fields, some strings, some numbers, one enum. Some existing code already manipulates this type. The fields of this type changed a couple of times during the project and could change again, so it would be nice if we minimized the pain of adapting to changed fields.

interface Product {
 name: string,
 price : number,
 unitOfMeasure : UnitOfMeasure,
 quantityPerUnit : number,
 brandName : string,
 productType : string,
 category : string,
}

During the course of this article, we're going to create two variants of Product: ProductStrings and ProductParsing. Our ultimate goal with these types is building a new hunk of code that uploads user-defined products. For our purposes that means writing a function parse:

const parse : (row: ProductStrings) => ProductParsing = ...

copy link

Different value types

Our first job is to make the ProductStrings type, which will have all the same keys as Product, but their types will all be strings. This represents the unparsed input to our parser function. If we don't mind repeating ourselves, we could duplicate, rename and modify the definition of Product, and replace every value with a string:

interface ProductStrings {
 name: string,
 price : string,
 unitOfMeasure : string,
 quantityPerUnit : string,
 brandName : string,
 productType : string,
 category : string,
}

But we don't like repeating ourselves. 

“keyof” and “Record”

TypeScript has better ways of doing this, using a combination of two new concepts:

type ProductStrings = Record<keyof Product, string>;
  • keyof Product extracts the keys of Product.
  • Record<K, T> maps keys in K to values of type T. All Records are Objects. Record is more specific than Object since all the values of a Record share the same type T.

So here ProductStrings is a type with all the same keys as Product, but their types are all strings. We've completed ProductStrings

Coping with illegal input

We wrote our first pass of a parsing function. It had the type signature:

const parse : (row: ProductStrings) => Product = ...

We simply crashed if customers were going to put in invalid strings. But of course, that was just a stopgap. Soon we supported graceful feedback if they put in nonsense like a price of "1.$00." Rejected products were collected into a spreadsheet and returned to the user for further editing, while valid products were inserted into the database. So we needed a new return type for our parse function that allowed for both success and failure. When things failed, we had to know the reason why they failed, so we could give them the help they needed to fix it. And when things succeeded, we needed the parsed values.

So how would we modify the return type of our parse function from the Product to a variation that allows each field to show either success or failure in parsing? Product could only represent success…

This took some time to research. The solution was the combination of three concepts: mapped types, generic type variables and union types. We'll present the concepts first, then assemble them to form the solution.

The user fills in a spreadsheet with one product's worth of information per row. There's a column for each key: name, price and so on. The user submits their spreadsheet, and rows are parsed individually. To be considered successful, every cell in a row needs to successfully parse. How can we model this in code? We're going to create a type called CellParse, with a parsing result for a single cell of the spreadsheet.

Union types

We know CellParse will be a JavaScript object. It has three responsibilities:

  1. To know whether the user put in a valid string.
  2. If the string is valid, and we need to know the parsed value to insert it into a database record.
  3. Otherwise, the string is invalid and we need to store the reason why to give the user an informative error message.

TypeScript's union types do this:

// not yet valid code
export type CellParse =
| { parsed: true; value: ????; }
| { parsed: false; reason: string; };

But what do we put in for ????? Sometimes it's a string. Sometimes it's a number. Sometimes it's an enum. The type of this value is whatever's called for by ContractProducts, e.g. string for brandName, number for quantityPerUnit and so on. We can defer that decision using generic type variables.

Generic type variables

We put off that decision by giving the type a variable to be supplied by the type that references it:

export type CellParse<T> =
| { parsed: true; value: T; }
| { parsed: false; reason: string; };

Here the generic type variable T appears in two places. In angle brackets just left of the =, it declares that the type will be generic. On the second line, as the value type, it serves as a place for the type compiler to paste in the actual type when the compiler can deduce it.

Choosing among union type alternatives

The other thing to see here is that the parsed property is present in both branches of CellParse. With properties of union types, your code is only allowed to reference them if one of two conditions is met:

  1. The property is present in all branches of the union type.
  2. The compiler can infer that the actual type has that property based on TypeScript's static analysis of the context.

What does that mean? In the first line of the following code, TypeScript understands that you can only get to the ‘then’ clause if cellParse.parsed is true, and you can only get to the ‘else’ clause if cellParse.parsed is false. Therefore it permits the references to cellParse.value in the ‘then’ and cellParse.reason in the 'else.'

if (cellParse.parsed) {
  return cellParse.value
} else {
  throw new Error(`parse error ${cellParse.reason}`)
}

Other references to either cellParse.value or cellParse.reason without first consulting cellParse.parsed will cause a compiler complaint, since the compiler can't rule it a safe access. This is an amazing control flow analysis on the part of TypeScript!

Now we've completed our exploration of the CellParse type, and we get to resume our journey and “alloy” it with Products.

Mapped types: Building up our Product-shaped type with CellParse values

We need another variation of Product, called ProductParsing. It will have the same keys, but instead of having values of type T, it'll have values of type CellParse<T>. That is, we want something equivalent to this:

interface ProductParsing {
  name: CellParse<string>,
  price : CellParse<number>,
  unitOfMeasure : CellParse<UnitOfMeasure>,
  quantityPerUnit : CellParse<number>,
  brandName : CellParse<string>,
  productType : CellParse<string>,
  category : CellParse<string>,
}

The TypeScript way of doing this is called a mapped type:

type ProductParsing = {[K in keyof Product]: CellParse<Product[K]>};

The first set of square brackets establish a mapping over the types of keyof Product, binding that type to K. Then the type Product[K] is the way of writing the original value type in Product. Take a minute to study that surprising syntax. This is certainly something I'm going to have to look up every time I use it!

This syntax is more powerful than the Record feature we used before. We can easily write ProductStrings in terms of mapped types, but we can't write ProductParsing in terms of Record. In fact, Record is internally implemented as a mapped type. 

The parser

With the types ProductStrings and ProductParsing, we're finally able to rewrite the type signature for our parsing function:

const parse : (row: ProductStrings) => ProductParsing = ...

With the proper typing in place, writing the parser is pretty straightforward and type-safe — in some sense a ‘one-liner’:

const parse : (row: ProductStrings) => ProductParsing = (row) => {
  return {
    name: parseName(row.name),
    price: parsePrice(row.price),
    unitOfMeasure: parseUnitOfMeasure(row.unitOfMeasure),
    quantityPerUnit: parseInteger(row.quantityPerUnit),
    brandName: parseString(row.brandName),
    productType: parseString(row.productType),
    category: parseString(row.category),
  };
};

You'll see that this delegates to a number of specialized field parsers — parseString, parsePrice, parseUnitOfMeasure, parseName and parseInteger — whose implementations are strongly typed but mostly beyond the scope of this article. We'll include parseInteger to show one example:

const parseInteger = (s: string): CellParse<number> => {
  const n = Number(s);
  if (Number.isNaN(n)) { return { parsed: false, reason: "does not contain a number" }; }
  if (!Number.isInteger(n)) { return { parsed: false, reason: "must be a whole number" }; }
  return { parsed: true, value: n };
};

parseInteger takes a string, then returns a CellParse<number>. If the parsed number fails to validate, a CellParse<number> with an error message is returned. Otherwise,  it returns CellParse<number> with a value. 

copy link

Wrapping up: What just happened?

By now we've seen a fair subset of the type operators in TypeScript, and seen how they apply to a real-world project: we've built up the typing for a parse function that uses two types derived from a simple initial record type. Adding a new key to our initial record requires only two code changes: changing the type definition itself and adding a line to the parser code.

In TypeScript, types have zero runtime cost and can be used to eliminate large classes of errors. By learning how to express types better, we make more correct code that is easier to maintain.