Luigi Cavalieri - Authoring Open Source Code

How to Download an Array of Data Objects as CSV File with TypeScript

Knowing how to implement the downloading 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.

How to Download an Array of Data Objects as CSV File with TypeScript

A requirement such as the downloading 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 = ( value: any, dataItem?: Record<string, any> ) => string;
2
3export interface CsvColumn {
4  key: string;
5  title: string;
6  formatValue?: CsvFormatValueCallback;
7}
8
9export const downloadCsv = ( 
10  data: Record<string, any>[], 
11  columns: CsvColumn[], 
12  filename: string
13) => {
14  const nullToEmptyReplacer = ( _key: string, value: any ) => {
15    return ( null === value ? "" : value );
16  };
17
18  // Converts a 'dataItem' object into an array of strings.
19  const prepareDataItem = ( dataItem: Record<string, any> ) => {
20    return columns.map( column => {
21      let value = ( dataItem[column.key] ?? "-" );
22
23      if ( column.key in dataItem ) {
24        if ( typeof column.formatValue === "function" ) {
25          value = column.formatValue( dataItem[column.key], dataItem );
26        }
27      }
28
29      return JSON.stringify( value, nullToEmptyReplacer );
30    });
31  };
32
33  const headingsRow = columns.map( column => column.title ).join( "," );
34  const contentRows = data.map( dataItem => {
35    return prepareDataItem( dataItem ).join( "," );
36  });
37
38  const csvDataString = [ headingsRow, ...contentRows ].join( "\r\n" );
39
40  const universalBom = "\uFEFF";
41  const blobParts    = [ universalBom + csvDataString ];
42  const blobOptions: BlobPropertyBag = {
43    type: "text/csv;charset=UTF-8"
44  };
45
46  const file = new Blob( blobParts, blobOptions );
47  const link = document.createElement( "a" );
48  
49  link.href     = window.URL.createObjectURL( file );
50  link.download = `${filename}.csv`;
51  link.click();
52};

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:

  1. 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.)
  2. 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 the data's items — since each property of a data's item relates to a different column
    • title is the column's heading as seen in the example at the beginning of the post
    • formatValue() 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
  3. 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
4const columns: CsvColumn[] = [
5  {
6    key: "name",
7    title: "Name"
8  },
9  {
10    key: "species",
11    title: "Animal Species"
12  },
13  {
14    key: "num_of_paws",
15    title: "Paws"
16  },
17  {
18    key: "willing_to_run",
19    title: "Willing to Run",
20    formatValue: ( value: boolean ) => {
21      return ( value ? "Yes" : "No" );
22    }
23  }
24];
25
26function App() {
27  const [ isProcessing, setIsProcessing ] = useState( false );
28  
29  const onClickDownload = async () => {
30    setIsProcessing( true );
31
32    const response = await fetch( "/animals.json" );
33    const data     = await response.json();
34
35    // Simulates a delay on fetching the data.
36    await new Promise( resolve => setTimeout( resolve, 3000 ) );
37
38    downloadCsv( data, columns, "Fictional Animals" );
39
40    setIsProcessing( false );
41  };
42
43  return (
44    <>
45      <button
46        type="button"
47        onClick={onClickDownload}
48        disabled={isProcessing}
49      >
50        {isProcessing ? (
51          "Please wait..."
52        ) : (
53          "Download data as CSV"
54        )}
55      </button>
56    </>
57  )
58}
59
60export default App;

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]

It's usually a good idea to provide visual feedbacks to the user when an input event is triggered, that's why both the text content and the disabled attribute of our button change while the download is in progress. The artificial delay introduced in the onClickDownload() function ensures that we can appreciate the change in state even if the downloading doesn't involve a remote fetch of data.

CSV download button's states
Visual feedbacks depicting a change in state of a UI element are invaluable!

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:

CSV file viewed with Numbers for Mac.
CSV file viewed with Numbers for Mac.

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!