How to Download an Array of Data Objects as CSV File with TypeScript
Knowing how to implement the download of data objects as CSV file with vanilla TypeScript is useful even for job interviews. In this post I cover the necessary information and code to accomplish the task.
A requirement such as the download of data objects as CSV file is almost ubiquitous, to such an extent you might encounter it even in the practical part of a job interview. Thus knowing how to implement it is undoubtedly valuable, even more so if one knows how to implement it without resorting to a third-party library. For this reason, today I want to show you how to package an array of data objects into a CSV file with just some plain TypeScript.
I will go even a step further by adding the ability to customise the formatting of the cells' value and the title of the columns. Also, we will see how to boost compatibility with Microsoft Excel by leveraging the use of the universal BOM.
The CSV Format, A Recap
CSV stands for Comma-separated Values, actually CSV files are just plain text files where each line represents a record made up always of the same number of cells, essentially comma-separated strings eventually enclosed within double quotes. The following is a sample of a valid CSV file's content:
1Forename,Surname,Country
2Mohandas Karamchand,Gandhi,India
3Italo,Calvino,Italy
4Adam,Sandler,United States
As you can see from the example, the first row generally contains the columns' header. Though, its presence is not a mandatory requirement of the file format.
We Mentioned the Universal BOM, but What is it?
The universal BOM or universal Byte Order Mark is a sequence of characters (\uFEFF
for UTF-8) which, prepended to the content of a UTF-8-encoded CSV, addresses an issue you might encounter by reading the CSV file with Microsoft Excel: when you open a CSV file with Excel, the latter uses a default ANSI encoding to read the file, even if the CSV was encoded in UTF-8, causing non-English characters, for instance, to look malformed. To prevent such behaviour, the universal BOM comes to the rescue. In fact by placing a universal BOM at the start of the CSV file's content we can force Excel into reading the CSV using UTF-8 encoding, in this way all characters are displayed correctly.
The Code Needed to Download Data Objects as CSV File
The TypeScript code we need to achieve our purpose can be encapsulated into a reusable function I named downloadCsv()
, which has three mandatory arguments I will document in a moment, or anyway the time you need to start getting familiar with the code:
1export type CsvFormatValueCallback<DataItem> = (
2 // eslint-disable-next-line
3 value: any,
4 dataItem: DataItem
5) => string;
6
7export interface CsvColumn<CallbackDataItem = Record<string, unknown>> {
8 key: string;
9 title: string;
10 formatValue?: CsvFormatValueCallback<CallbackDataItem>;
11}
12
13export const downloadCsv = <DataItem = Record<string, unknown>>(
14 data: DataItem[],
15 columns: CsvColumn<DataItem>[],
16 filename: string
17) => {
18 const nullToEmptyReplacer = ( _key: string, value: unknown ) => {
19 return ( null === value ? "" : value );
20 };
21
22 // Converts a 'DataItem' object into an array of strings.
23 const prepareDataItem = ( item: DataItem ) => {
24 return columns.map( column => {
25 let value;
26 const key = column.key as keyof DataItem;
27
28 try {
29 value = item[key] ?? "-";
30
31 if ( typeof column.formatValue === "function" ) {
32 value = column.formatValue( item[key], item );
33 }
34 }
35 catch {
36 value = "-";
37 }
38
39 return JSON.stringify( value, nullToEmptyReplacer );
40 });
41 };
42
43 const headingsRow = columns.map( column => column.title ).join( "," );
44 const contentRows = data.map( dataItem => {
45 return prepareDataItem( dataItem ).join( "," );
46 });
47
48 const csvDataString = [ headingsRow, ...contentRows ].join( "\r\n" );
49
50 const universalBom = "\uFEFF";
51 const blobParts = [ universalBom + csvDataString ];
52 const blobOptions: BlobPropertyBag = {
53 type: "text/csv;charset=UTF-8"
54 };
55
56 const file = new Blob( blobParts, blobOptions );
57 const link = document.createElement( "a" );
58
59 link.href = window.URL.createObjectURL( file );
60 link.download = `${filename}.csv`;
61 link.click();
62};
The Input Arguments
Probably, the columns
argument is the only one which may raise some doubts. Anyway don't worry, we are about to look at them one by one:
- data: the array of data objects we want to download, I say data objects because each object should contain only properties whose values are primitive data (strings, numbers, booleans, etc.)
- columns: array of metadata objects of type
CsvColumn
. As you may have read,CsvColumn
is a custom type which describes objects carrying information about the columns of the CSV file:key
must be equal to one of the keys of thedata
's items — since each property of adata
's item relates to a different columntitle
is the column's heading as seen in the example at the beginning of the postformatValue()
is an optional callback which can be used to give a specific formatting to the cells' value of a given column. The second argument of this callback is there because the formatting of a value could depend upon other properties of the same data object — we will see a use case for this callback later on
- filename: the extension-less name of the CSV file
A Detail Concealing a Not-less-useful Functionality
For a moment I want to draw your attention on the prepareDataItem()
callback. This callback has the purpose of converting a data object into the corresponding array of strings that, once joined, makes up a record of the CSV. As you can see, the conversion process is based upon a loop over the items of the columns
array. This detail alone is enough to ensure that the columns whose metadata are not present in the columns
array are automatically excluded from the output CSV.
Now I think we are ready for an exemplification, so let's React!
The Code in Action: An Example React App
As a use case I thought to write a simple React application whose visible part is just a button. A button that when clicked induces the app to fetch an array of data objects and then to pass it in input to our dowloadCsv()
, the function that does all the conversion work and triggers the download on the fly of our CSV file. The App.tsx
script is the following:
1import { useState } from "react";
2import { downloadCsv, CsvColumn } from "./library/csv";
3
4interface Animal {
5 id: number;
6 name: string;
7 species: string;
8 num_of_paws: number;
9 willing_to_run: boolean;
10}
11
12const columns: CsvColumn<Animal>[] = [
13 {
14 key: "name",
15 title: "Name"
16 },
17 {
18 key: "species",
19 title: "Animal Species"
20 },
21 {
22 key: "num_of_paws",
23 title: "Paws"
24 },
25 {
26 key: "willing_to_run",
27 title: "Willing to Run",
28 formatValue: ( value: boolean ) => {
29 return ( value ? "Yes" : "No" );
30 }
31 }
32];
33
34export default function App() {
35 const [ isProcessing, setIsProcessing ] = useState( false );
36
37 const onClickDownload = async () => {
38 setIsProcessing( true );
39
40 const response = await fetch( "/animals.json" );
41 const data = await response.json() as Animal[];
42
43 // Simulates a delay on fetching the data.
44 await new Promise( resolve => setTimeout( resolve, 3000 ) );
45
46 downloadCsv( data, columns, "Fictional Animals" );
47
48 setIsProcessing( false );
49 };
50
51 return (
52 <>
53 <button
54 type="button"
55 onClick={onClickDownload}
56 disabled={isProcessing}
57 >
58 {isProcessing ? (
59 "Please wait..."
60 ) : (
61 "Download data as CSV"
62 )}
63 </button>
64 </>
65 )
66}
To run the code I would suggest to create a new project using Vite.
As you can notice, the onClickDownload()
contains an artificial delay just after the fetch()
call. That because the animals.json
file isn't supposed to be fetched from a remote server, on the contrary, to allow trouble-free reproducibility of the code, the file can be just placed in the public
folder of the project. This is its content:
1[
2 {
3 "id": 1,
4 "name": "Balto",
5 "species": "Dog",
6 "num_of_paws": 4,
7 "willing_to_run": true
8 },
9 {
10 "id": 2,
11 "name": "The Road Runner",
12 "species": "Bird",
13 "num_of_paws": 2,
14 "willing_to_run": true
15 },
16 {
17 "id": 3,
18 "name": "Garfield",
19 "species": "Cat",
20 "num_of_paws": 4,
21 "willing_to_run": false
22 }
23]
The artificial delay introduced in the onClickDownload()
function ensures that we can appreciate the change in state of the button even if the download doesn't involve a remote fetch of data.
Another thing to note is that although I wrote the columns
array in the same file as the App
component, in a real world application it would be better to place this metadata array into its own file.
Finally, as we were saying earlier, to provide an example about excluding a column from the CSV, I purposely avoided including in the columns
array the metadata object related to the id
field of the objects hard-coded in animals.json
. In fact the generated CSV file, opened with Numbers for Mac, will look like this:
To note also as the formatting of the values of the willing_to_run
column is controlled by the formatValue()
callback, which converts a boolean into a more user-friendly information.
Why is there a "No" in the willing_to_run
column for Garfield? Well, because we are talking about Garfield after all!