This is my fifth dev.to post. Previous posts: Auth System, AI Resume Analyzer, Structured AI Output, and Profile Editor Type Safety.
const liveProfile = mergeFormValuesWithProfile(
profile,
values
) as unknown as FullProfile;
I stared at that line. as unknown as FullProfile. The "type hammer."
It worked. The PDF preview updated when users typed. But TypeScript had given up. I was lying to the compiler, and deep down, I knew this would bite me.
This is the story of how I refactored a working feature into something actually maintainable - introducing a Domain Model that decouples my PDF templates from both Prisma and TanStack Form. And a code review that taught me about React 19's stricter rules.
The Stack
| Package | Version | Purpose |
|---|---|---|
| Next.js | 16.0.10 | App framework |
| React | 19.2.3 | UI library |
| TanStack Form | 1.27.1 | Type-safe form state |
| Zod | 4.1.13 | Schema validation & type source |
| @react-pdf/renderer | 4.3.1 | Live PDF generation |
| use-debounce | 10.0.6 | Debounce for performance |
Chapter 1: The Problem with Coupling
The Mess I Had
My live PDF preview worked, but the architecture was fragile. The ResumeTemplate component expected a FullProfile - a type derived from Prisma:
// ❌ Tightly coupled to database structure
type FullProfile = NonNullable<Awaited<ReturnType<typeof getFullProfile>>>;
interface ResumeTemplateProps {
profile: FullProfile;
}
This type included database metadata: id, createdAt, updatedAt, profileId. My templates didn't need any of that. But because the type demanded it, I had to fake it:
// ❌ The type hammer in action
const liveProfile = {
...profile,
...values,
workExperiences: values.workExperiences.map((exp) => ({
...exp,
startDate: exp.startDate ? new Date(exp.startDate) : null,
endDate: exp.endDate ? new Date(exp.endDate) : null,
})),
// ... repeat for 6 more sections
} as unknown as FullProfile;
That as unknown as FullProfile was a red flag. I was telling TypeScript, "Trust me, this is a FullProfile" - even though it wasn't. Missing fields, wrong types, runtime crashes waiting to happen.
Why This Matters
- No type safety - TypeScript couldn't catch missing or wrong fields
- Tight coupling - Templates knew about database structure
- Scaling nightmare - Adding new templates meant copying the same casting logic
- Maintenance hell - Change the database schema, and who knows what breaks
Chapter 2: The Domain Model Solution
The Insight
The PDF template doesn't care where data comes from. It just needs to know:
- What's the person's name?
- What jobs did they have?
- What skills do they have?
That's it. No id, no createdAt, no profileId. Just content.
Creating the Domain Model
I created a new file: lib/types/resume.ts. This defines what a "Resume" actually is in my application:
// lib/types/resume.ts
import { z } from "zod/v4";
// Define the schema FIRST
export const ResumeDataSchema = z.object({
displayName: z.string(),
headline: z.string().nullable(),
email: z.string().nullable(),
phone: z.string().nullable(),
website: z.string().nullable(),
location: z.string().nullable(),
bio: z.string().nullable(),
socialLinks: z.array(
z.object({
platform: z.string(),
url: z.string(),
label: z.string().nullable(),
})
),
workExperiences: z.array(
z.object({
company: z.string(),
position: z.string(),
location: z.string().nullable(),
startDate: z.date().nullable(),
endDate: z.date().nullable(),
current: z.boolean(),
bullets: z.array(z.string()),
})
),
// ... educations, projects, skills, etc.
});
// Derive the type FROM the schema
export type ResumeData = z.infer<typeof ResumeDataSchema>;
Key insight: No id fields. No database metadata. Just the content that appears on a resume.
The Transformation Layer
Now I needed functions to convert data sources to this domain model:
// lib/profile-transformation.ts
/**
* Database Profile → Resume Domain Data
*/
export function profileToResume(profile: FullProfile): ResumeData {
return {
displayName: profile.displayName || "",
headline: profile.headline,
email: profile.email,
// ... map all fields, stripping database metadata
workExperiences: profile.workExperiences.map((exp) => ({
company: exp.company,
position: exp.position,
// Notice: no id, no createdAt, no updatedAt
startDate: exp.startDate,
endDate: exp.endDate,
current: exp.current,
bullets: exp.bullets,
})),
};
}
/**
* Form Values → Resume Domain Data
*/
export function formToResume(formValues: ProfileFormValues): ResumeData {
return {
displayName: formValues.displayName,
headline: formValues.headline || null,
// ... map all fields, converting strings to Dates
workExperiences: formValues.workExperiences.map((exp) => ({
company: exp.company,
position: exp.position,
startDate: exp.startDate ? new Date(exp.startDate) : null,
endDate: exp.endDate ? new Date(exp.endDate) : null,
current: exp.current,
bullets: exp.bullets,
})),
};
}
Two data sources. One common output. No type hammers.
Refactoring the Template
Now the template only cares about ResumeData:
// components/pdf/resume-template.tsx
import type { ResumeData } from "@/lib/types/resume";
interface ResumeTemplateProps {
data: ResumeData; // ✅ Clean domain type, not database type
}
export function ResumeTemplate({ data }: ResumeTemplateProps) {
return (
<Document>
<Page size="A4" style={styles.page}>
<Text style={styles.name}>{data.displayName}</Text>
{data.workExperiences.map((exp, index) => (
<View key={index} style={styles.experienceItem}>
{/* Notice: key={index} because ResumeData has no IDs */}
<Text>{exp.position} at {exp.company}</Text>
</View>
))}
</Page>
</Document>
);
}
Why key={index}? The domain model doesn't have IDs because IDs are a database concern. In a PDF document (which is static and remounts on every change), index-based keys are perfectly safe.
The Profile Editor Update
Now the live preview is beautifully simple:
// components/profile-editor/profile-editor.tsx
<form.Subscribe selector={(state) => [state.values]}>
{([values]) => {
const resumeData = formToResume(values); // ✅ Clean transformation
return <PDFPreview profile={resumeData} />;
}}
</form.Subscribe>
No more as unknown as FullProfile. TypeScript is happy. I'm happy.
Chapter 3: The Three-Layer Architecture
Here's what I ended up with:
┌─────────────────────────────────────────────────────────────┐
│ DATA SOURCES │
├─────────────────────────────┬───────────────────────────────┤
│ Database (Prisma) │ Form (TanStack) │
│ FullProfile type │ ProfileFormValues type │
│ Has: id, createdAt... │ Has: string dates │
└──────────────┬──────────────┴───────────────┬───────────────┘
│ │
▼ ▼
profileToResume() formToResume()
│ │
└──────────────┬───────────────┘
▼
┌──────────────────────────────┐
│ DOMAIN MODEL │
│ ResumeData │
│ No IDs, no DB metadata │
│ Just content │
└──────────────┬───────────────┘
│
▼
┌──────────────────────────────┐
│ TEMPLATES │
│ ResumeTemplate │
│ FutureTemplate1 │
│ FutureTemplate2 │
└──────────────────────────────┘
Benefits:
- Add 10 new templates without touching Prisma or form code
- Change database schema without breaking templates
- Form validation and template rendering are completely decoupled
- TypeScript catches errors at compile time, not runtime
Chapter 4: The Code Review That Almost Broke Everything
The Performance Suggestion
During code review, I got feedback about a performance concern in the PDF viewer:
"Using
JSON.stringifyon the entire resume data object can be expensive. Consider using a revision counter instead."
The suggestion:
// Code review suggestion
const revisionRef = useRef(0);
useEffect(() => {
revisionRef.current += 1;
}, [debouncedData]);
const viewerKey = `viewer-${revisionRef.current}`;
Smart thinking! JSON.stringify on every render does seem wasteful. I merged it.
React 19 Said No
Immediately hit this error:
Error: Cannot access refs during render
React refs are values that are not needed for rendering.
Refs should only be accessed outside of render...
React 19 enforces stricter rules. Reading revisionRef.current during render is now forbidden.
The Fix That Didn't Work
I tried moving to state:
const [revision, setRevision] = useState(0);
useEffect(() => {
setRevision((r) => r + 1);
}, [debouncedData]);
const viewerKey = `viewer-${revision}`;
TypeScript was happy. React was happy. But the PDF crashed:
TypeError: Eo is not a function
at PDFViewerClient
Why? The useEffect runs after render. So on the first render after data changes:
- Data changes
- Component renders with OLD key
-
@react-pdf/renderertries to update with new data - 💥 Internal reconciler crashes
- THEN
useEffectruns and updates the key - Too late
The Lesson: Correctness Over Micro-Optimization
I reverted to JSON.stringify:
// components/pdf/pdf-viewer-client.tsx
export function PDFViewerClient({ data }: PDFViewerClientProps) {
const [debouncedData] = useDebounce(data, 500);
const document = useMemo(
() => <ResumeTemplate data={debouncedData} />,
[debouncedData]
);
// Note: JSON.stringify on resume data (~50KB max) is acceptable;
// more complex hashing would add unnecessary complexity.
const viewerKey = JSON.stringify(debouncedData);
return (
<PDFViewer key={viewerKey} style={styles.viewer}>
{document}
</PDFViewer>
);
}
For a resume-sized object (~10-50KB), JSON.stringify takes microseconds. The performance "problem" didn't exist. But the bugs from "optimizing" it did.
Sometimes the "slower" solution is the right one.
Chapter 5: Other Code Review Wins
Not all the suggestions broke things. Some were genuinely good:
1. Redundant new Date() Wrapper
// ❌ Before - redundant
export function formatDateForInput(date: Date | null): string {
if (!date) return "";
return new Date(date).toISOString().split("T")[0];
}
// ✅ After - simplified
export function formatDateForInput(date: Date | null): string {
if (!date) return "";
return date.toISOString().split("T")[0];
}
TypeScript already guarantees date is a Date. No need to wrap it again.
2. Explicit Defensive Date Handling
// ❌ Before - implicit defense
function formatDate(date: Date | null): string {
if (!date) return "";
const d = new Date(date);
if (isNaN(d.getTime())) return "";
return new Intl.DateTimeFormat("en-US", {
month: "short",
year: "numeric",
}).format(d);
}
// ✅ After - explicit defense with comment
function formatDate(date: Date | null): string {
if (!date) return "";
// Defensive: handle string dates from API/JSON that may bypass type system at runtime
const d = date instanceof Date ? date : new Date(date);
if (isNaN(d.getTime())) return "";
return new Intl.DateTimeFormat("en-US", {
month: "short",
year: "numeric",
}).format(d);
}
The instanceof check makes the defensive intent explicit. Future developers will know why this code exists.
TL;DR
| Problem | Solution |
|---|---|
Type hammer (as unknown as FullProfile) |
Domain Model (ResumeData) with transformation functions |
| Templates coupled to database types | Templates only know about ResumeData
|
| Database IDs in PDF template keys | Use index - domain model has no IDs |
JSON.stringify performance concern |
Keep it - correctness > micro-optimization |
| React 19 ref access during render | Don't access refs during render, use state with useEffect
|
Redundant new Date() wrapper |
Trust TypeScript, remove the wrapper |
Key Lessons
1. Domain Models are worth the effort.
Creating a separate type for "what the resume contains" (vs "what Prisma returns") eliminated an entire category of bugs and made the codebase ready for multiple templates.
2. The "as unknown as" pattern is a code smell.
If you're casting through unknown, you've lost type safety. Step back and redesign.
3. React 19 is stricter about refs.
You can't read refs during render anymore. Use state if you need values during render.
4. Performance intuition can be wrong.
JSON.stringify on a small object is fast. Premature optimization introduced real bugs.
5. Code reviews are valuable, even when they're wrong.
The suggestion didn't work, but the discussion led to documenting why we use JSON.stringify and understanding React 19's new rules.
6. Make defensive code explicit.
If you're guarding against runtime type mismatches, add a comment. Future you will thank present you.
What's Next
- Multiple resume templates - The domain model makes this trivial
- Template preview gallery - Show all templates side by side
- AI-powered suggestions - Use the resume analyzer to improve content inline
- Public portfolio pages - Share resumes via URL
Why Open Source?
Every refactor, every bug fix, every "I can't believe that didn't work" moment - it's all in the repo.
If this post saves you from the "type hammer" pattern or helps you understand domain models, it was worth writing. Architecture decisions are the hardest to learn from tutorials - they need context and mistakes.
The repo: github.com/ImAbdullahJan/carific.ai
⭐ If you find this useful, consider starring the repo - it helps others discover the project!
Key files from this post:
-
lib/types/resume.ts- The domain model -
lib/profile-transformation.ts- Transformation functions -
components/pdf/resume-template.tsx- Template using domain model -
components/pdf/pdf-viewer-client.tsx- PDF viewer with debounce
Your Turn
I'd love feedback:
- On the architecture: Is domain model overkill for your projects? When would you use it?
- On the post: Did the diagrams help? Too much code?
- On React 19: Have you hit the stricter ref rules?
Building in public only works if there's a public to build with.
If this post helped you, drop a ❤️. It means more than you know.
Let's connect:
- 🐙 GitHub: @ImAbdullahJan - ⭐ Star the repo if you found this helpful!
- 🐦 Twitter/X: @abdullahjan - Follow for more dev content
- 👥 LinkedIn: abdullahjan - Let's connect
Fifth post of many. See you in the next one.
Top comments (0)