Skip to content

Latest commit

 

History

History
226 lines (170 loc) · 8.95 KB

CONTRIBUTING.md

File metadata and controls

226 lines (170 loc) · 8.95 KB

Contributing guide

We're always looking for new contributors and are happy to help you get started! Some ideas:

  • Add a test to a function, like an interesting edge case we haven't thought about.
  • Improve a function's documentation.
  • Fix a bug or an edge case.
  • Improve the documentation's migration guides.
  • Work on issues labeled good first issue
  • Work on issues labeled help wanted

Getting started

We use npm as the package manager. We recommend using nvm to manage Node.js and npm versions. If you're not using nvm, the .nvmrc has the recommended Node version.

Install dependencies:

git clone [email protected]:remeda/remeda.git
cd remeda
npm install

Run tests and watch for changes:

npm test # every test
npm test -i src/file.test.ts # specific test

We have a pre-commit script that will format and lint the project, but if you want to run it manually:

npm run prettier # format
npm run lint # lint

Documentation

Most of the content in the documentation is generated from the JSDoc comments. If you want to work on the design of the docs website or the migration guides, see docs/README.md.

Guidelines

Adding a new function

When adding a new function, remember to:

  • Add a JSDoc comment with a description, parameters, signature, an example, and tags.
    • This will end up as the documentation on the website.
  • Add tests for runtime, typing, data-first, and data-last forms.
  • Add exports to src/index.ts.
  • Check for equivalent functions in Lodash and Ramda, and add them to docs/src/content/mapping.

Writing types

We pay careful attention to types. Some tips for writing good types:

  • We use type-fest as our type utility library. We also have type utilities in internal/types.
  • We use the Simplify type from type-fest for simplifying some output types.
  • We generally use the IterableContainer type from internal/types for input arrays. Similarly, we generally use the object type for input objects.
Examples:

An example with an array:

const data = ["a", 1] as [string, number];

// ❌ `T` can be too wide:
function functionName<T>(data: ReadonlyArray<T>): ReadonlyArray<T> {}

functionName(data); // inside functionName, data[0] has type string | number
// output has type ReadonlyArray<string | number>

// ✅ `T` is more specific:
function functionName<T extends IterableContainer>(data: T): T {}

functionName(data); // inside functionName, data[0] has type string
// output has type [string, number]

An example with an object:

const data = { a: "a", b: 1 };

// ❌ `T` can be too wide:
function functionName<T>(data: Readonly<Record<PropertyKey, T>>) {}

functionName(data); // inside functionName, data.a has type string | number

// ✅ `T` is more specific:
function functionName<T extends object>(data: T) {}

functionName(data); // inside functionName, data.a has type string

Writing tests

Runtime tests for fileName.ts live in fileName.test.ts, and typing tests live in fileName.test-d.ts. Some guidelines:

  • We have separate tests for data-first and data-last forms.
This typically looks like this:
// <functionName>.test.ts

describe("data-first", () => {
  test("test description", () => {
    expect(/* ... */).toBe(/* ... */);
  });
});

describe("data-last", () => {
  test("test description", () => {
    expect(/* ... */).toBe(/* ... */);
  });
});
// <functionName>.test-d.ts

describe("data-first", () => {
  test("test description", () => {
    expectTypeOf(/* ... */).toEqualTypeOf</* ... */>();
  });
});

describe("data-last", () => {
  test("test description", () => {
    expectTypeOf(/* ... */).toEqualTypeOf</* ... */>();
  });
});
  • For data-last tests, both runtime and typing, prefer tests using pipe. This better matches real-world usage.
  • Each test block should test one thing. Most of the time this means having only one expect per test.
  • Tests should provide 100% coverage; the Codecov bot will comment on your PR with a coverage report.
  • We test for types using expectTypeOf, rather than assertType.
  • We also test for meaningful type errors. Prefer ts-expect-error, rather than @ts-ignore:
test("doesn't accept non-literal depths", () => {
  // @ts-expect-error [ts2345] - non-literal numbers can't be used as depth.
  flat([], 1 as number);
});
  • We don't test runtime behavior for type errors, as we can only guarantee behavior when the types are correct.
  • When testing types, cover as many use-cases as possible.
Some types to consider:
  • Numbers
    • Number type (number)
    • Single literal (1)
    • Union of literals (1 | 2)
    • Bigint type (bigint)
    • Single bigint literal (1n)
    • Union of bigint literals (1n | 2n)
    • Unions of numbers and bigints (1 | 2n)
  • Strings
    • String type (string)
    • Single literal ("cat")
    • Union of literals ("cat" | "dog")
    • Template with a type slot (`id_${number}`)
    • Template with a literal slot (`id_${1 | 2}`)
    • Template with multiple slots (`id_${1 | 2}_${3 | 4}`)
  • Arrays
    • Array of a single type (Array<number>)
    • Array of a union type (Array<string | number>)
    • Array of literal types (Array<"cat" | "dog">)
    • Union of similar arrays (Array<1 | 2> | Array<2 | 3>)
    • Nested arrays (Array<Array<number>>)
  • Tuples
    • Tuple of a single type ([number, number, number])
    • Tuple of different types ([number, string, boolean])
    • Tuple with optional type ([number, string?])
    • Tuple with spreads ([...Array<number>, number, number])
    • Tuple with optional type and spread ([number?, ...Array<string>])
    • Tuple with union values ([number, string | undefined, boolean])
    • Tuple with literal values ([number, "cat" | "dog", true])
  • Records
    • Record with string keys (Record<string, number>)
    • Record with number keys (Record<number, string>)
    • Record with union keys (Record<string | number, unknown>)
    • Record with union values (Record<string, string | number>)
    • Record with undefined values (Record<string, string | undefined>)
    • Record with literal keys (Record<"cat" | "dog", number>)
    • Record with literal values (Record<string, 1 | 2>)
    • Record with template keys (Record<`id_${number}`, string>)
    • Union of records (Record<string, unknown> | Record<number, unknown>)
  • Objects
    • Object with named keys ({ a: number })
    • Object with union values ({ a: string | number })
    • Object with literal union values ({ a: "cat" | 1 })
    • Object with optional keys ({ a?: number })
    • Object with symbol keys ({ [Symbol("a")]: number })
    • Union of objects ({ a: 1, b: 2 } | { b: 2, c: 3 })
    • Nested objects ({ a: { b: { c: 1 } } })
  • readonly versions of the above
  • null and undefined

Design philosophy

No type annotations. Functions shouldn't require type annotations to have good types. This gives the best developer experience.

Have good output types. One reason Remeda provide "one-liner" functions is if we can have better output types than the defaults. While data.filter(item => typeof item === 'number') works, filter(data, isNumber) gives a better output type. This is why we have type guards, hasAtLeast, prop, etc.

Readability counts. The other reason Remeda provides one-liner functions is if they make things more readable. Compare map((item) => item + 3) to map(add(3)), where we don't need to come up with a name for item. This is why we have constant, doNothing, identity, etc.

Prefer composition. We don't provide simple compositions of functions. Having a smaller surface area makes it easier for users to remember Remeda's functions, and easier for us to maintain. We don't have reject because it's the same as filter(isNot), and we don't have zipObject because it's the same as fromEntries(zip).

Minimal implementations. Implementations should be small and self-contained. Importing an individual function should add less than 500 B to the bundle size in most cases.