Skip to content

Commit

Permalink
feat: improved TypeScript support (fixes #2)
Browse files Browse the repository at this point in the history
BREAKING CHANGE:

The API is now generic, so you can use the library like `json2csv<User>(...)` and
`csv2json<User>(...)`. The typings of the value getters and setters (`getValue` and
`setValue`) is changed, and the type of `NestedObject` is changed.
  • Loading branch information
josdejong committed Jun 2, 2023
1 parent 3435b13 commit 9ec74ae
Show file tree
Hide file tree
Showing 6 changed files with 57 additions and 46 deletions.
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,8 @@ console.log(csvFlat)
// The CSV output can be fully customized and transformed using `fields`:
const csvCustom = json2csv(users, {
fields: [
{ name: 'name', getValue: (object) => object.name },
{ name: 'address', getValue: (object) => object.address.city + ' - ' + object.address.street }
{ name: 'name', getValue: (item) => item.name },
{ name: 'address', getValue: (item) => item.address.city + ' - ' + object.address.street }
]
})
console.log(csvCustom)
Expand Down Expand Up @@ -108,8 +108,8 @@ console.log(usersFlat)
// The JSON output can be customized using `fields`
const usersCustom = csv2json(csv, {
fields: [
{ name: 'name', setValue: (object, value) => (object.name = value) },
{ name: 'address.city', setValue: (object, value) => (object.city = value) }
{ name: 'name', setValue: (item, value) => (item.name = value) },
{ name: 'address.city', setValue: (item, value) => (item.city = value) }
]
})
console.log(usersCustom)
Expand All @@ -121,7 +121,7 @@ console.log(usersCustom)

## API

### `json2csv(json: NestedObject[], options?: CsvOptions) : string`
### `json2csv<T>(json: T[], options?: CsvOptions<T>) : string`

Where `options` is an object with the following properties:

Expand All @@ -131,7 +131,7 @@ Where `options` is an object with the following properties:
| `delimiter` | `string` | Default delimiter is `,`. A delimiter must be a single character. |
| `eol` | `\r\n` or `\n` | End of line, can be `\r\n` (default) or `\n`. |
| `flatten` | `boolean` or `(value: unknown) => boolean` | If `true` (default), plain, nested objects will be flattened in multiple CSV columns, and arrays and classes will be serialized in a single field. When `false`, nested objects will be serialized as JSON in a single CSV field. This behavior can be customized by providing your own callback function for `flatten`. For example, to flatten objects and arrays, you can use `json2csv(json, { flatten: isObjectOrArray })`, and to flatten a specific class, you can use `json2csv(json, { flatten: value => isObject(value) \|\| isCustomClass(value) })`. The option `flatten`is not applicable when`fields` is defined. |
| `fields` | `CsvField[]` or `CsvFieldsParser` | A list with fields to be put into the CSV file. This allows specifying the order of the fields and which fields to include/excluded. |
| `fields` | `CsvField<T>[]` or `CsvFieldsParser<T>` | A list with fields to be put into the CSV file. This allows specifying the order of the fields and which fields to include/excluded. |
| `formatValue` | `ValueFormatter` | Function used to change any type of value into a serialized string for the CSV. The build in formatter will only enclose values in quotes when necessary, and will stringify nested JSON objects. |

A simple example of a `ValueFormatter` is the following. This formatter will enclose every value in quotes:
Expand All @@ -142,7 +142,7 @@ function formatValue(value: unknown): string {
}
```

### `csv2json(csv: string, options?: JsonOptions) : NestedObject[]`
### `csv2json<T>(csv: string, options?: JsonOptions) : T[]`

Where `options` is an object with the following properties:

Expand Down
14 changes: 8 additions & 6 deletions src/csv2json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ import { parseValue, unescapeValue } from './value.js'
import { mapFields, toFields } from './fields.js'
import { isCRLF, isEol, isLF, validateDelimiter } from './validate.js'

export function csv2json(csv: string, options?: JsonOptions): NestedObject[] {
export function csv2json<T>(csv: string, options?: JsonOptions): T[] {
const withHeader = options?.header !== false // true when not specified
const delimiter: number = validateDelimiter(options?.delimiter || ',').charCodeAt(0)
const quote = 0x22 // char code of "
const parse = options?.parseValue || parseValue

const json: NestedObject[] = []
const items: T[] = []
let i = 0

const fieldNames = parseHeader()
Expand All @@ -22,16 +22,18 @@ export function csv2json(csv: string, options?: JsonOptions): NestedObject[] {
: toFields(fieldNames, options?.nested !== false)

while (i < csv.length) {
const object = {}
// Note that item starts as a generic, empty object, and will be populated
// with all fields one by one, after which it should be of type T
const item: NestedObject = {}

parseRecord((value, index) => {
fields[index]?.setValue(object, value)
fields[index]?.setValue(item, value)
}, parse)

json.push(object)
items.push(item as T)
}

return json
return items

function parseHeader(): string[] {
const names: string[] = []
Expand Down
45 changes: 25 additions & 20 deletions src/fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { getIn, isObject, setIn } from './object.js'
import { parsePath, stringifyPath } from './path.js'
import { CsvField, FlattenCallback, JsonField, NestedObject, Path, ValueGetter } from './types.js'

export function collectFields(records: NestedObject[], flatten: FlattenCallback): CsvField[] {
export function collectFields<T>(records: T[], flatten: FlattenCallback): CsvField<T>[] {
return collectNestedPaths(records, flatten).map((path) => ({
name: stringifyPath(path),
getValue: createGetValue(path)
Expand Down Expand Up @@ -38,11 +38,10 @@ export function mapFields(fieldNames: string[], fields: JsonField[]): (JsonField

for (let field of fields) {
// an indexOf inside a for loop is inefficient, but it's ok since we're not dealing with a large array
// const index = typeof field.index === 'number' ? field.index : fieldNames.indexOf(field.name)
// @ts-ignore
const index = field.index !== undefined ? field.index : fieldNames.indexOf(field.name)
if (index === -1) {
// @ts-ignores
// @ts-ignore
throw new Error(`Field "${field.name}" not found in the csv data`)
}

Expand All @@ -58,11 +57,16 @@ export function mapFields(fieldNames: string[], fields: JsonField[]): (JsonField

const leaf = Symbol()

export function collectNestedPaths(array: NestedObject[], recurse: FlattenCallback): Path[] {
const merged: NestedObject = {}
type MergedObject = {
[key: string]: MergedObject
[leaf]?: boolean | null
}

export function collectNestedPaths<T>(array: T[], recurse: FlattenCallback): Path[] {
const merged: MergedObject = {}
array.forEach((item) => {
if (recurse(item) || isObject(item)) {
_mergeObject(item, merged, recurse)
_mergeObject(item as NestedObject, merged, recurse)
} else {
_mergeValue(item, merged)
}
Expand All @@ -76,13 +80,14 @@ export function collectNestedPaths(array: NestedObject[], recurse: FlattenCallba

// internal function for collectNestedPaths
// mutates the argument `merged`
function _mergeObject(object: NestedObject, merged: NestedObject, recurse: FlattenCallback): void {
function _mergeObject(object: NestedObject, merged: MergedObject, recurse: FlattenCallback): void {
for (const key in object) {
const value = object[key]
const valueMerged = merged[key] || (merged[key] = Array.isArray(value) ? [] : {})
const valueMerged =
merged[key] || (merged[key] = (Array.isArray(value) ? [] : {}) as MergedObject)

if (recurse(value)) {
_mergeObject(value as NestedObject, valueMerged as NestedObject, recurse)
_mergeObject(value as NestedObject, valueMerged as MergedObject, recurse)
} else {
_mergeValue(value, valueMerged)
}
Expand All @@ -91,37 +96,37 @@ function _mergeObject(object: NestedObject, merged: NestedObject, recurse: Flatt

// internal function for collectNestedPaths
// mutates the argument `merged`
function _mergeValue(value: NestedObject, merged: NestedObject) {
function _mergeValue(value: unknown, merged: MergedObject) {
if (merged[leaf] === undefined) {
merged[leaf] = value === null || value === undefined ? null : true
}
}

// internal function for collectNestedPaths
// mutates the argument `paths`
function _collectPaths(object: NestedObject, parentPath: Path, paths: Path[]): void {
if (object[leaf] === true || (object[leaf] === null && isEmpty(object))) {
function _collectPaths(merged: MergedObject, parentPath: Path, paths: Path[]): void {
if (merged[leaf] === true || (merged[leaf] === null && isEmpty(merged))) {
paths.push(parentPath)
} else if (Array.isArray(object)) {
object.forEach((item, index) => _collectPaths(item, parentPath.concat(index), paths))
} else if (isObject(object)) {
for (const key in object) {
_collectPaths(object[key], parentPath.concat(key), paths)
} else if (Array.isArray(merged)) {
merged.forEach((item, index) => _collectPaths(item, parentPath.concat(index), paths))
} else if (isObject(merged)) {
for (const key in merged) {
_collectPaths(merged[key], parentPath.concat(key), paths)
}
}
}

function createGetValue(path: Path): ValueGetter {
function createGetValue<T>(path: Path): ValueGetter<T> {
if (path.length === 1) {
const key = path[0]
return (item) => item[key]
return (item) => (item as NestedObject)[key]
}

// Note: we could also create optimized functions for 2 and 3 keys,
// a rough benchmark showed that does not have a significant performance improvement
// (like only 2% faster or so, and depending a lot on the data structure)

return (item) => getIn(item, path)
return (item) => getIn(item as NestedObject, path)
}

function isEmpty(object: NestedObject): boolean {
Expand Down
7 changes: 6 additions & 1 deletion src/json2csv.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,13 @@ import { json2csv } from './json2csv'
import spectrum from 'csv-spectrum'
import { isObject, isObjectOrArray } from './object'

interface User {
id: number
name: string
}

describe('json2csv', () => {
const users = [
const users: User[] = [
{ id: 1, name: 'Joe' },
{ id: 2, name: 'Sarah' }
]
Expand Down
6 changes: 3 additions & 3 deletions src/json2csv.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { collectFields } from './fields.js'
import { createFormatValue } from './value.js'
import { CsvOptions, FlattenCallback, NestedObject } from './types.js'
import { CsvOptions, FlattenCallback } from './types.js'
import { validateDelimiter, validateEOL } from './validate.js'
import { isObject } from './object.js'

export function json2csv(json: NestedObject[], options?: CsvOptions): string {
export function json2csv<T>(json: T[], options?: CsvOptions<T>): string {
const header = options?.header !== false // true when not specified
const delimiter = validateDelimiter(options?.delimiter || ',')
const eol = validateEOL(options?.eol || '\r\n')
Expand Down Expand Up @@ -37,7 +37,7 @@ export function json2csv(json: NestedObject[], options?: CsvOptions): string {
return fields.map((field) => formatValue(field.name)).join(delimiter)
}

function rowToCsv(item: NestedObject): string {
function rowToCsv(item: T): string {
return fields.map((field) => formatValue(field.getValue(item))).join(delimiter)
}
}
17 changes: 8 additions & 9 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
// Note that a number in a Path has meaning: that means an array index and not an object key
export type Path = (string | number)[]

/** @ts-ignore **/
export type NestedObject = Record<string, NestedObject>
export type NestedObject = { [key: string]: NestedObject | unknown }

export type ValueGetter = (object: NestedObject) => unknown
export type ValueSetter = (object: NestedObject, value: unknown) => void
export type ValueGetter<T> = (item: T) => unknown
export type ValueSetter = (item: NestedObject, value: unknown) => void
export type ValueFormatter = (value: unknown) => string
export type ValueParser = (value: string) => unknown

export type FlattenCallback = (value: unknown) => boolean

export interface CsvField {
export interface CsvField<T> {
name: string
getValue: ValueGetter
getValue: ValueGetter<T>
}

export interface JsonFieldName {
Expand All @@ -26,15 +25,15 @@ export interface JsonFieldIndex {
}
export type JsonField = JsonFieldName | JsonFieldIndex

export type CsvFieldsParser = (json: NestedObject[]) => CsvField[]
export type CsvFieldsParser<T> = (json: T[]) => CsvField<T>[]
export type JsonFieldsParser = (fieldNames: string[]) => JsonField[]

export interface CsvOptions {
export interface CsvOptions<T> {
header?: boolean
delimiter?: string
eol?: '\r\n' | '\n'
flatten?: boolean | FlattenCallback
fields?: CsvField[] | CsvFieldsParser
fields?: CsvField<T>[] | CsvFieldsParser<T>
formatValue?: ValueFormatter
}

Expand Down

0 comments on commit 9ec74ae

Please sign in to comment.