Back to blogs
Blog
Writing in public
Longer posts — published here first, then cross-posted to Dev.to with a canonical URL back to this site.
Blog
Longer posts — published here first, then cross-posted to Dev.to with a canonical URL back to this site.
Back to blogs
Back to blogs
Postnpm, react, typescript, community
slides used in the presentation building the generic component In this example we'll use...
Date published

Why You Might Still Pick Next.js Over TanStack Start From a TanStack Start...

A complete theme management system for React applications with SSR support! ✨ 🌟...

A guide to creating Android home screen widgets using Expo modules, complete with state management,...
In this example we'll use a tale to demostrate how to build a generic component that adapts to any type of data
to go from a fully hardcoded componenst to a
we first put the tables rows into an array and then pass that array to the component which we loop over to get the table rows
<video controls src="https://github.com/tigawanna/ReactDevsKe-Meetup-February-2025/raw/d45806eca0a9a6b67f047b439894905a0b712450/docs/rows-to-mapped-rows.mp4" title="rows-to-mapped-rows"></video>
And the same can also be done to the columns
<video controls src="https://github.com/tigawanna/ReactDevsKe-Meetup-February-2025/raw/d45806eca0a9a6b67f047b439894905a0b712450/docs/static-to-dynamic-coumns.mp4" title="static-to-dynamic-coumns"></video>
and now our table mponent can take in the data and the columns and then render the table rows and columns
1type Player = {2 id: number;3 name: string;4 age: number;5 rank: number;6}78type TableColumn = {9 label: string;10 accessor: keyof Player;11};12interface DynamicPlayerstableProps {13 columns: Array<TableColumn>;14 data: Array<Player>;15}1617export function DynamicPlayerstable({data,columns}: DynamicPlayerstableProps) {18 return ....19}
is now decoupled from the data but we can take this further by decouping it from the shape of data. In it's current form it requires us to pass in data like this
1const columns: Array<TableColumn> = [2 { label: "ID", accessor: "id" },3 { accessor: "name", label: "Name" },4 { accessor: "age", label: "Age" },5 { accessor: "rank", label: "Rank" },6];78const data: Array<Player> = [9 { id: 1, name: "Player1", age: 25, rank: "Silver" },10 { id: 2, name: "Player2", age: 30, rank: "Gold" },11 { id: 3, name: "Player3", age: 22, rank: "Bronze" },12 { id: 4, name: "Player4", age: 28, rank: "Platinum" },13 { id: 5, name: "Player5", age: 24, rank: "Diamond" },14 { id: 6, name: "Player6", age: 27, rank: "Silver" },15 { id: 7, name: "Player7", age: 29, rank: "Gold" },16];17return <DynamicPlayerstable columns={columns} data={data} />;
The table structure is dynamic enough to accomodate any array of objects we pass in with the only required field being od id because we use it as the key to every row
1<tbody>2 {data.map((player) => (3 <tr key={player.id}>4 {columns.map((column) => (5 <td key={column.accessor}>{player[column.accessor]}</td>6 ))}7 </tr>8 ))}9</tbody>
To accomplish this we'll need to use a generic type usuaally maerked as T which is a way to pass in variables to out types and interfaces
firdt we pass it into the component
1export function DynamicPlayerstable<T>({ data, columns }: DynamicPlayerstableProps);
then we pass it into our parameter interface
1interface DynamicPlayerstableProps<T> {2 columns: Array<TableColumn>;3 data: Array<Player>;4}56export function DynamicPlayerstable<T>({ data, columns }: DynamicPlayerstableProps<T>);
this type T should be the type of the object that we pass in to the array so we can now replace type Player with T
1interface DynamicPlayerstableProps<T> {2 columns: Array<TableColumn>;3 data: Array<T>;4}
our TbaleColumn type was also relying on the type Player so the type is also going to change to TableColumn<T> so we can now replace type Player with T
<video controls src="https://github.com/tigawanna/ReactDevsKe-Meetup-February-2025/raw/d45806eca0a9a6b67f047b439894905a0b712450/docs/type-Player-to-T.mp4" title="type-Player-to-T"></video>
At this point typescipt will be able to give us auto complete for the coluns field based on what type T is based on the array we pass into the data field
with player rows array 
with teams row array 
 Out component is working fine from the outside but typescript is having a hader time understanding the types inside the component sine generice type T could be any type and it's keys could be numver|string|symbol , symbols aren't allowed as react keys so we can narrow that type by using an intersection & to inform typescrpt that the keys of T must be a string like we had in type Player
1type TableColumn<T> = {2 label: string;3 accessor: keyof T & string;4};5interface DynamicPlayerstableProps<T> {6 columns: Array<TableColumn<T>>;7 data: Array<T>;8}
That resolves that issue but introduces another one where typescipt doesn't know what type T is and what type T[keyof T] is going to resolve to
Because in it's current shape bothe
1type Player = {2 id: number;3 name: string;4 age: number;5 rank: string;6};7// and89type playeyWithArrays = {10 id: Array<number>;11 name: Array<string>;12 age: Array<number>;13 rank: Date;14 ratio: {15 numerator: number;16 denominator: number;17 };18};
>[!TIP]
any type can be passed into the component as typeT, react and our table doesn't expect that and will throw an error if we try to render an array orDateobject in atdor any react node . so to further specify what inputs we expect we can use theextendsoperator
>[!NOTE]
in typescript it's either used to inherit behaviour from a class or to mark a generic type as a subtype of another more specific type
1T extends string // can only accept strings2T extends {} // can only accept objects3T extends {id:number} // can only accept objects with a key `id` that is a number4T extends Record<string, string | number> // can only accept objects with string or number keys
We'll use this to specify that only objects with string or number keys can be passed into the table
>[!NOTE]
Record<TKey, TValue> can be used to specify objects1type TableColumn<T extends Record<string, string|number >> = {2 label: string;3 accessor: keyof T & string;4};5interface GenericTableProps<T extends Record<string, string|number >> {6 columns: Array<TableColumn<T>>;7 data: Array<T>;8}910export function GenericTable<T extends Record<string, string|number >>
now with this we can only pass in objects that have string or number keys
1const data = [2 {3 id: 1,4 name: "John Doe",5 age: 30,6 rank: "Gold",7 },8];9const baddata = [10 {11 id: 1,12 name: "John Doe",13 age: [30],14 rank: new Date(),15 },16];
before restrictions  after restrictions 
one last thing is rqeire type T to include a key id because it's going to be used as the key for the table rows
1type GenericItem = Record<string, string | number> & { id: string }; // field of id:string required in passed in objects2type TableColumn<T extends GenericItem> = {3 label: string;4 accessor: keyof T & string;5};6interface GenericTableProps<T extends GenericItem> {7 columns: Array<TableColumn<T>>;8 data: Array<T>;9}1011export function GenericPlayerstable<T extends GenericItem>({ data, columns }: GenericTableProps<T>);

>[!TIP] >Bonus tip : handling nested objects
1const data = [2 {3 id: "1",4 name: "John Doe",5 age: 30,6 rank: "Gold",7 ratio: {8 numerator: 1,9 denominator: 2,10 },11 },12];
This type will cause issues because field ratio is an object and we can't render it directly in the table because react can't render objects as its children
First the Record type should also intersect with an object type
1// now lets the value of the record to be an object2type GenericItem = Record<string, string | number | object> & { id: string };3type TableColumn<T extends GenericItem> = {4 label: string;5 accessor: PossibleNestedUnions<T, 10> & string;6};78interface GenericTableProps<T extends GenericItem> {9 title:string;10 description?:string;11 columns: Array<TableColumn<T>>;12 data: Array<T>;13}
Then we add checks to handle object types
1 <tbody>2 {data.map((row) => (3 <tr key={row.id}>4 {columns.map((column) => {5 const value = row[column.accessor];6 // this line grabs the nested object and maps it to a <td>7 if (column.accessor.includes(".")) {8 return <td key={column.accessor}>{getNestedProperty(row, column.accessor)}</td>;9 }10 // we've already checked if the accessor is a nested object but typescript is not yet aware and11 // still thinks value is type string | number | objct at this point12 // this part is only here to narrow the type or catch objects types that fell through13 // so that the value type on the section below is of type string | number14 if (typeof value === "object") {15 const nestedValue = getNestedProperty(row, column.accessor)16 if(typeof nestedValue !== "string" || typeof nestedValue !== "number") {17 // to avoid accidentally trying to renedr objects as react children18 return <td key={column.accessor}>{JSON.stringify(nestedValue)}</td>;19 }20 return <td key={column.accessor}>{getNestedProperty(row, column.accessor)}</td>;21 }22 // value type is string| number , safe to render ina react td23 return <td key={column.accessor}>{value}</td>;24 })}25 </tr>26 ))}27 </tbody>
getNestedProperty is a utility function to retrieve a nested property value from an object based on a dot-separated path
example
1const obj = { a: { b: { c: 42 } } };2console.log(getNestedProperty(obj, "a")); // Outputs: { b: { c: 42 } }3console.log(getNestedProperty(obj, "a.b.c")); // Outputs: 424console.log(getNestedProperty(obj, "a.b.x")); // Outputs: undefined
so how do we get the types to the dot separated values? this one is non trivial and one of the examples of the ugly typescript people hate. PossibleNestedUnions uses recursion to extract the dot separated keys to inputs of type nested object
example
1type ExampleType = {2 a: string;3 b: {4 c: number;5 d: {6 e: boolean;7 f: {8 g: string;9 };10 };11 };12 h: Date;13};1415type NestedKeys1 = PossibleNestedUnions<ExampleType, 1>; // "a" | "b" | "h"16type NestedKeys2 = PossibleNestedUnions<ExampleType, 2>; // "a" | "b" | "b.c" | "b.d" | "h"17type NestedKeys3 = PossibleNestedUnions<ExampleType, 3>; // "a" | "b" | "b.c" | "b.d" | "b.d.e" | "b.d.f" | "h"18type NestedKeysAll = PossibleNestedUnions<ExampleType>; // includes all nested paths
and now our new type will be
1import { PossibleNestedUnions } from "../types/nested_objects_union";2import { getNestedProperty } from "../utils/object";34type GenericItem = Record<string, string | number | object>&{id:string}5type TableColumn<T extends GenericItem> = {6 label: string;7 accessor: PossibleNestedUnions<T> & string;8};9interface GenericTableProps<T extends GenericItem> {10 columns: Array<TableColumn<T>>;11 data: Array<T>;12}
with the array
1 const gooddata = [2 {3 id: "1",4 name: "John Doe",5 age: 30,6 rank: "Gold",7 stats:{8 running:20,9 jumping:10,10 swimming:30,11 weapons:{12 stars:10,13 katana:514 }15 }16 },17 {18 id: "2",19 name: "Pedro",20 age: 30,21 rank: "Gold",22 stats:{23 running:20,24 jumping:10,25 swimming:30,26 weapons:{27 stars:1,28 katana:4,29 blades:{30 short:10,31 long:532 }33 }34 }35 },36 ];
we'll get auto complete for all the possibly nested types 
Finally we can make the title and description of the table dynamic too because we can render more than just players with this component
1 <GenericTableWithNestedFields2 title="Players Table"3 description="list of players without their stats"4 data={gooddata}5 columns={[6 { accessor: "id", label: "age" },7 { accessor: "name", label: "name" },8 { accessor: "age", label: "age" },9 { accessor: "rank", label: "rank" },10 ]}11 />12 <GenericTableWithNestedFields13 title="Players Table with stats"14 description="list of players with their stats"15 data={gooddata}16 columns={[17 { accessor: "id", label: "age" },18 { accessor: "name", label: "name" },19 { accessor: "age", label: "age" },20 { accessor: "rank", label: "rank" },21 { accessor: "stats.running", label: "running" },22 { accessor: "stats.jumping", label: "jumping" },23 { accessor: "stats.swimming", label: "swimming" },24 { accessor: "stats.weapons.blades.short", label: "katana" },25 ]}26 />
Publishing to npm requires 3 things
in our case we also need
1npm init -y2npm install typescript --save-dev3tsc --init
we'll change the tsconfig to look like this source + explanation
1{2 "compilerOptions": {3 /* Base Options: */4 "esModuleInterop": true,5 "skipLibCheck": true,6 "target": "es2022",7 "allowJs": true,8 "resolveJsonModule": true,9 "moduleDetection": "force",10 "isolatedModules": true,11 "verbatimModuleSyntax": true,1213 /* Strictness */14 "strict": true,15 "noUncheckedIndexedAccess": true,16 "noImplicitOverride": true,1718 /* If transpiling with TypeScript: */19 // "module": "NodeNext",20 "outDir": "dist",21 // "sourceMap": true,2223 /* AND if you're building for a library: */24 "declaration": true,2526 /* AND if you're building for a library in a monorepo: */27 // "composite": true,28 // "declarationMap": true,2930 /* If NOT transpiling with TypeScript: */31 "module": "preserve",32 "noEmit": true,33 /* If your code doesn't run in the DOM: */34 // "lib": ["es2022"]35 /* If your code runs in the DOM: */36 "lib": ["es2022", "dom", "dom.iterable"],37 // if you want to use JSX (react)38 "jsx":"react-jsx",3940 "paths": {41 "@/*": ["./src/*"]42 },4344 },45 // only files under `src` will be transpiled46 "include": ["src"],47 // exclude files under `dist` and `node_modules`48 "exclude": ["dist", "node_modules"]49}
Our file will be in the src directory with an index.ts file as the main entrypoint
>[!NOTE]
Entrypoint is the file that is executed when the package is imported
example
1// my-table-package is the name of the package which is the default entrypoint2import { GenericTable } from "my-table-package";
entry points are defined in the package.json file
1{2 "name": "my-table-package",3 "version": "1.0.0",4 "type": "module",5 // our transipiled file will be in the `dist` directory6 "files": [7 "dist"8 ],9 // our entrypoint is the `index.ts` file10 "main": "dist/index.js",11 // the exports field is a newer syntax for node 16+ that allows for multiple entrypoints12 "exports": {13 ".": {14 "import": "./dist/index.js",15 "require": "./dist/index.cjs"16 },17 // we can also define exports for specific files18 "/styles":"./dist/index.css"19 },20 // peer dependencies are dependencies that we expect the user of the package to already have installed , if we're unsure that thy'll have it we put it i the dependencies field (it might lead to bigger final size)21 "peerDependencies": {22 "react": "^18.0.0 || ^19.0.0",23 "react-dom": "^18.0.0 || ^19.0.0",24 "tailwindcss": "^3.0.0 || ^4.0.0",25 "daisyui": " ^4.0.0 || ^5.0.0-beta"26 },27 // devDependencies are dependencies that are only used for development and don't get included in the final package28 // if uo have peer dependencies u can put them in dev dependencies29 "devDependencies": {30 "@arethetypeswrong/cli": "^0.17.3",31 "@changesets/cli": "^2.27.12",32 "@eslint/js": "^9.19.0",33 "@types/node": "^22.12.0",34 "@types/react": "^19.0.8",35 "daisyui": "5.0.0-beta.6",36 "eslint": "^9.19.0",37 "prettier": "^3.4.2",38 "react": "^19.0.0",39 "tailwindcss": "^4.0.1",40 "tsup": "^8.3.6",41 "typescript": "^5.7.3"42 }43}
We'll build this package using tsup . we can use tsc (the typescript compiler) to build the package but that would require some changes
```json
//tsconfig.json
{
/* If transpiling with TypeScript: */
"module": "NodeNext",
"sourceMap": true,
}
12which requires that all our import should end with the file extension name3
ts import { GenericTable } from "./src/components/GenericTable"; // to be import { GenericTable } from "./src/components/GenericTable.js";
1which can be a bit much if we already have the code we want to publish2to use tsup we just add a config3
ts //tsup.config.ts import { defineConfig } from "tsup";
export default defineConfig({ entry: ["src/index.ts","src/components/RedSquare.tsx"], splitting: false, format: ["cjs", "esm"], dts: true, clean: true, minify: false, platform: "browser", });
1 and with our build script2
json { "scripts": { "dev": "tsup --watch", "build": "tsup", "format": "prettier --write .", "typecheck": "tsc", "lint": "eslint --quiet .", "ci:version": "changeset version", "ci:release": "changeset publish", "release": "changeset publish --no-git-checks", "check-exports": "attw --pack . --ignore-rules=cjs-resolves-to-esm --profile node16" } }
sh npm run build
12the outputs should be in the `dist` directory3and tio test if our component works (am using pnpm)4we check the working directory using `pwd` then head to the project we want to install the package in and run5
sh pnpm install <file-path-to-package>
1which adds a linked package like this2
json "dependencies": { "@tailwindcss/vite": "^4.0.1", //locally linked package "my-table-package": "link:/home/dennis/Desktop/packaging/my-table-package", "pp": "^1.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", "tailwindcss": "^4.0.1" }
12>[!NOTE]3if u're not using pnpm u can use `npm link` instead of `pnpm install`45>[!WARNING]6This will only work if you build yoour project locally where the package project also is , if you ad this to a projecct that get's build in CI or any other machnie it will not work78to make it usable by everyone we publish it to npm using
sh npm publish
12>[!NOTE]3 once at least one project installs your library it will be4 undeletable from the npm registry56### Extras7- check exports with [are the types correct](https://github.com/arethetypeswrong/arethetypeswrong.github.io#readme)8to chek if everything is being exported correctly9
json { "scripts": { "dev": "tsup --watch", "build": "tsup", "format": "prettier --write .", "typecheck": "tsc", "lint": "eslint --quiet .", "ci:version": "changeset version", "ci:release": "changeset publish", "release": "changeset publish --no-git-checks", "check-exports": "attw --pack . --ignore-rules=cjs-resolves-to-esm --profile node16" } }
sh npm run check-exports
12- [create a changelog](https://blog.logrocket.com/automatically-generate-and-release-a-changelog-with-node-js/)3- write unit tests4- use other entrypoint , useful for56 - optimize and minimize the bundle size by allowing tree-shaking7 - separate and organize code into logical modules for better maintainability8 - enable independent development and testing of different components9 - facilitate conditional loading of features based on environment or user preferences10 - improve application startup time by loading only necessary code11 - enhance code reuse across different parts of the application or projects1213example
ts // /src/inde: main entrypoint contents
import { GenericTable } from "./components/GenericTable"; import { GenericTableWithNestedFields } from "./components/GenericTableWithNestedFields"; import { HardCodedTable } from "./components/HardCodedTable";
export { GenericTable, GenericTableWithNestedFields, HardCodedTable };
ts import { defineConfig } from "tsup";
export default defineConfig({ // our entrypoint is the index.ts file // RedSquare.tsx component will be build with it's own entrypoint entry: ["src/index.ts","src/components/RedSquare.tsx"], splitting: false, format: ["cjs", "esm"], dts: true, clean: true, minify: false, platform: "browser",
});
json { "exports": { // default entrypoint
".": { "import": "./dist/index.js", "require": "./dist/index.cjs" }, // /redbox entrypoint "./redbox": "./dist/components/RedSquare.js" }, }
ts // import the default entrypoint import { GenericTable } from "my-table-package"; // import the redbox entrypoint import { RedBox} from "my-table-package/redbox";
```