From 59c88182150e857d811ee9a63de6a20663a84e09 Mon Sep 17 00:00:00 2001 From: Christian Schurr Date: Mon, 9 Nov 2026 13:15:15 +0100 Subject: [PATCH] test: add type tests for the public API Add src/__tests__/types.test-d.ts with expectTypeOf assertions for parseFormData (parameters, accepted inputs including native File values, return type), ParseFormDataOptions (removeEmptyString, depth, transformEntry signature) and the error classes. A transformEntry that returns a Date must not compile (issue #3). npm run typecheck checks the file. Exclude *.test-d.ts from vitest's runtime run: its include pattern matched the file, and at runtime every expectTypeOf call is a no-op, so the tests passed without checking anything. --- src/__tests__/types.test-d.ts | 159 ++++++++++++++++++++++++++++++++++ vitest.config.mts | 11 ++- 2 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/types.test-d.ts diff --git a/src/__tests__/types.test-d.ts b/src/__tests__/types.test-d.ts new file mode 100644 index 0000000..5c9aa53 --- /dev/null +++ b/src/__tests__/types.test-d.ts @@ -0,0 +1,159 @@ +// Type tests for the public API. `npm run typecheck` checks them (tsconfig +// includes `src`); vitest doesn't run this file (see `vitest.config.mts`). +// The expected types are written out here instead of imported from +// `src/types.ts`, so a change there fails these tests. + +import {describe, expectTypeOf, it} from 'vitest' +import { + DepthLimitError, + DuplicateKeyError, + ForbiddenKeyError, + InvalidPathError, + MixedArrayError, + parseFormData, + type ParseFormDataOptions, +} from '../' + +type ExpectedObject = {[Key in string]?: ExpectedValue} +type ExpectedValue = + | string + | number + | ExpectedObject + | Array + | boolean + | null + | File +type ExpectedLeafValue = string | number | boolean | null | File +type ExpectedEntry = [path: string, value: string | File] +type ExpectedTransformResult = {path: string; value: ExpectedLeafValue} + +describe('parseFormData', () => { + it('takes an iterable of entries and optional options', () => { + expectTypeOf(parseFormData) + .parameter(0) + .toEqualTypeOf>() + expectTypeOf(parseFormData) + .parameter(1) + .toEqualTypeOf() + }) + + it('accepts FormData, URLSearchParams and arrays of entries', () => { + expectTypeOf(parseFormData).toBeCallableWith(new FormData()) + expectTypeOf(parseFormData).toBeCallableWith(new URLSearchParams('a=x')) + expectTypeOf(parseFormData).toBeCallableWith([['a', 'x']]) + expectTypeOf(parseFormData).toBeCallableWith([], {}) + }) + + it('accepts entries with a native File value', () => { + expectTypeOf(parseFormData).toBeCallableWith([ + ['a', new File(['x'], 'a.txt')], + ]) + }) + + it('rejects entries with other values', () => { + // @ts-expect-error -- a number isn't a FormData value. + expectTypeOf(parseFormData).toBeCallableWith([['a', 1]]) + // @ts-expect-error -- a Blob isn't a File (no `name`). + expectTypeOf(parseFormData).toBeCallableWith([['a', new Blob(['x'])]]) + }) + + it('returns an object of JSON values and Files', () => { + expectTypeOf(parseFormData).returns.toEqualTypeOf() + }) +}) + +describe('ParseFormDataOptions', () => { + it('has removeEmptyString, depth and transformEntry, all optional', () => { + expectTypeOf().toEqualTypeOf<{ + removeEmptyString?: boolean + depth?: number + transformEntry?: ( + entry: ExpectedEntry, + defaultTransform: (entry: ExpectedEntry) => ExpectedTransformResult, + ) => ExpectedTransformResult + }>() + }) + + it('accepts a transformEntry that uses defaultTransform', () => { + expectTypeOf(parseFormData).toBeCallableWith([], { + transformEntry: ([path, value], defaultTransform) => + defaultTransform([path.toLowerCase(), value]), + }) + }) + + it('rejects a transformEntry that returns a Date', () => { + // Known limitation, see issue #3: the value must be a JSON leaf or File. + const options: ParseFormDataOptions = { + // @ts-expect-error -- Date isn't assignable to the value type. + transformEntry: ([path]) => ({path, value: new Date()}), + } + expectTypeOf(options).toEqualTypeOf() + }) +}) + +describe('error classes', () => { + it('DuplicateKeyError', () => { + expectTypeOf(DuplicateKeyError).constructorParameters.toEqualTypeOf< + [key: string] + >() + expectTypeOf(DuplicateKeyError).instance.toExtend() + expectTypeOf(DuplicateKeyError) + .instance.toHaveProperty('key') + .toEqualTypeOf() + expectTypeOf(DuplicateKeyError) + .instance.toHaveProperty('name') + .toEqualTypeOf() + }) + + it('MixedArrayError', () => { + expectTypeOf(MixedArrayError).constructorParameters.toEqualTypeOf< + [key: string] + >() + expectTypeOf(MixedArrayError).instance.toExtend() + expectTypeOf(MixedArrayError) + .instance.toHaveProperty('key') + .toEqualTypeOf() + expectTypeOf(MixedArrayError) + .instance.toHaveProperty('name') + .toEqualTypeOf() + }) + + it('ForbiddenKeyError', () => { + expectTypeOf(ForbiddenKeyError).constructorParameters.toEqualTypeOf< + [key: string] + >() + expectTypeOf(ForbiddenKeyError).instance.toExtend() + expectTypeOf(ForbiddenKeyError) + .instance.toHaveProperty('key') + .toEqualTypeOf() + expectTypeOf(ForbiddenKeyError) + .instance.toHaveProperty('name') + .toEqualTypeOf() + }) + + it('DepthLimitError', () => { + expectTypeOf(DepthLimitError).constructorParameters.toEqualTypeOf< + [key: string] + >() + expectTypeOf(DepthLimitError).instance.toExtend() + expectTypeOf(DepthLimitError) + .instance.toHaveProperty('key') + .toEqualTypeOf() + expectTypeOf(DepthLimitError) + .instance.toHaveProperty('name') + .toEqualTypeOf() + }) + + it('InvalidPathError', () => { + expectTypeOf(InvalidPathError).constructorParameters.toEqualTypeOf< + [key: string] + >() + expectTypeOf(InvalidPathError).instance.toExtend() + expectTypeOf(InvalidPathError) + .instance.toHaveProperty('key') + .toEqualTypeOf() + expectTypeOf(InvalidPathError) + .instance.toHaveProperty('name') + .toEqualTypeOf() + }) +}) diff --git a/vitest.config.mts b/vitest.config.mts index 7294014..c1ab2d0 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -3,8 +3,15 @@ import {configDefaults, defineConfig, type ViteUserConfig} from 'vitest/config' const config: ViteUserConfig = defineConfig({ test: { include: ['src/**/__tests__/**/*.ts'], - // Shared helpers, not a test file. - exclude: [...configDefaults.exclude, 'src/__tests__/utils.ts'], + exclude: [ + ...configDefaults.exclude, + // Shared helpers, not a test file. + 'src/__tests__/utils.ts', + // Type tests: `npm run typecheck` checks them. At runtime every + // `expectTypeOf` call is a no-op, so they would pass without testing + // anything. + 'src/**/*.test-d.ts', + ], coverage: { provider: 'v8', include: ['src/**/*.ts'],