TypeScript Records and Mapped Types
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.
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 = ...
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 ofProduct
.Record<K, T>
maps keys inK
to values of typeT
. All Records are Objects.Record
is more specific thanObject
since all the values of aRecord
share the same typeT
.
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:
- To know whether the user put in a valid string.
- If the string is valid, and we need to know the parsed value to insert it into a database record.
- 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:
- The property is present in all branches of the union type.
- 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.
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.