Repository navigation
Add a type-level test suite and align client types with runtime behavior - #33
Open
kac-axelor wants to merge 13 commits into
Open
kac-axelor wants to merge 13 commits into
kac-axelor wants to merge 13 commits into
Conversation
- pnpm test:types typechecks the *.test-d.ts suites via vitest typecheck mode; its global setup only generates the test client, no database needed. - The suites assert what the runtime should deliver: payload shapes from doQuery/ensureLazy, row counts as numbers, and aggregate values per their contract — computed aggregates (count/sum/avg) as the driver's exact strings, min/max/groupBy as the column's model type (Decimal stays BigDecimal). - 13 of 63 assertions fail on this commit by design: they are the expectations, and each following commit fixes one contract until the suite is green.
count() returns getCount() and delete/updateAll/deleteAll return `affected ?? 0` — always numbers at runtime. Promise<ID> forced callers to handle a string case that never occurs.
doQuery assigns the result of getCount() to record._count.
doQuery assigns an array to collection properties — empty when there are no related records, never null. The null came from the model field's optionality, which does not apply to query results.
- loadLazy resolves the column value or null on every read, so the aliases declare Promise<V | null> at the source instead of payload types patching it in. - PayloadSelect passes lazy fields through unchanged, dropping only the model field's optionality null: ensureLazy always installs the getter on selected fields. - The old payload type Json | null claimed the field itself could be null while hiding that the awaited value can be.
create/createAll without a select load getSimpleSelect() — plain scalar columns only. OmitByType never matched optional fields (their null member fails the extends check), so every nullable relation and text/jsonb/oid column leaked into the declared result.
- count/sum/avg have no column type to preserve: pg widens them (sum(int) is bigint, avg(int) is numeric) and the driver delivers exact numeric strings, which now pass through untouched. - Converting to the right precision becomes the caller's choice: Number() for display math, BigDecimal for exact arithmetic, BigInt for huge counts. - count is never null (NULL becomes "0"); sum/avg stay null over empty or all-null sets; parseInt truncation of decimal sums (3.75 read as 3) disappears along with the parsing.
- min/max return stored column values, so the result keeps the column's type: ints are numbers, strings stay strings, timestamps are Dates, and Decimal columns are BigDecimal — the Date/string/boolean conditional chain with its number fallthrough collapses to T | null. - null covers the empty or all-null set, which the database reports as NULL.
runAggregate passes column nulls through for groupBy; the null group is a real bucket. The declared type stripped it via NonNullable.
- min/max/groupBy carry stored column values, so each keeps its column's model type — the old value-shape sniffing returned negative decimals as raw strings, grouped numeric-looking string keys like "007" as the number 7, parsed bigint ids to numbers against their string typing, and let date columns through as driver Date objects. - The aggregate path now resolves to the column's metadata (walking relations) and converts only where the driver's shape differs from entity hydration: numeric columns become BigDecimal, date columns become date strings via TypeORM's own DateUtils; everything else passes through. - e2e covers negative decimal min/max/groupBy as BigDecimal, leading-zero string group keys, bigint id group keys, and date/time/timestamp aggregates.
The ORM auto-fills a collection's mappedBy field from the parent
record, yet nested creates still demanded it, forcing callers to pass
dummy links like `contact: {}`.
Carry the schema's mappedBy key into the generated model types through
a branded collection type — ToMany<T, MappedBy> — emitted for OneToMany
fields, and relax exactly that field in nested CreateArgs. Mirror the
rule in the GraphQL schema with per-relation CreateWithout<Field>Input
variants, generated only when the target declares the field required.
Direct creates keep requiring the field: there is no parent to fill it
from. Runtime tests drop the now-unnecessary contact: {} workaround.
- null group buckets for nullable columns, empty-set nulls and the "0" count, boolean and enum grouping, grouping through relations, and integer sum/avg as exact strings with min/max as numbers.
- count/sum/avg arrive as the database's exact decimal strings; converting to the right precision (Number, BigDecimal, BigInt) is the caller's choice. - min/max/groupBy keep the column's model type, including BigDecimal for Decimal columns, with nulls for empty sets and the null group bucket.
This was referenced Jun 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The client's declared result types and what the runtime actually delivers had
drifted apart across payloads, row counts, and aggregates — including one real
runtime bug (decimal sums truncated by
parseInt). This PR adds a type-leveltest harness (
pnpm test:types) whose first commit is the complete spec ofexpectations, then fixes every mismatch, finishing with a redesigned aggregate
result contract.
How to review
Read commit by commit: the first commit asserts 63 expectations (13 red on
devby design), every later commit is pure fix code, and the failing countis the narrative:
test:typesafterPayload and repository fixes
count/delete/updateAll/deleteAllPromise<ID>(string | number)Promise<number>_countpagination metadatastringgetCount()numberT[] | nullwhen optional[]when empty)T[]Json/Text/Binary)Json | null(null outside)Promise<V | null>create/updatewithout select (PayloadSimple)mappedBy)Aggregate contract (breaking change)
Computed values — no column type exists for these, so a result type is
designed; the value is always exact and conversion is the caller's choice:
countnumberstring, never null (no rows →"0")sumnumberparseInttruncated decimals (3.75 → 3); null untypedstring | null, exactavgnumberstring | null, exactColumn values —
min/max/groupByreturn stored values, which now keep thefield's own type, exactly as a
findwould deliver them:number/BigDecimalBigDecimalnumber(no null)numberstring"007"grouped as the number7stringboolean(no null)booleanstringDateobjectstringstringstringDate(no null)DateDatestringstringnulldelivered| nullfor nullable columns and empty setsConversion is driven by column metadata (resolved through relations), not by
what the value looks like.
Migration
row.sum.total + 10Number(row.sum.total) + 10row.count.id > 0Number(row.count.id) > 0sumnew BigDecimal(row.sum.total)min.population === 1.25min.population?.equals(new BigDecimal("1.25"))BigInt(row.count.id)Tests
pnpm test:typespnpm test:e2epnpm test