In this post, we'll dive into an intriguing topic: creating TypeScript functions that will enhance our codebase by generating TypeScript code. If you've ever worked with type generation from a GraphQL or database schema, you'll appreciate the convenience of these auto-generated types. Imagine having to manually write them each time the schema changes! While there might not always be a library tailored to our specific needs, fear not. Crafting our own code generator is straightforward. Rather than addressing a hypothetical issue, I'll guide you through a real use-case I encountered. The final demonstration is available on this demo page, and you can access the complete source code in my RadzionKit repository.
Here's the challenge: I run a productivity app called Increaser. In it, users can set up a public profile that displays their country. We record the country in our database using the ISO 3166-1 alpha-2 standard—a two-letter country code. But on the front-end, we need a user-friendly way for individuals to select their country. This means having a map that links each country code to its full name. Moreover, we aim to enhance the user interface by showing a country flag next to the user's name. Thus, we need a dynamic React component that can display the correct flag based on the provided country code.
We already possess a JSON file containing all the country codes and names, along with a directory filled with SVGs of the country flags. We could directly utilize the JSON, but that wouldn't be ideal. Furthermore, while we could host the SVGs on a CDN, this approach would compromise the reusability of our component. Why? Because any consumer of the component would then need to be concerned about the hosting location of the SVGs. This is where the power of code generation truly shines.
Let's tackle the most straightforward task first. We'll transform the JSON file into a TypeScript file. This file should:
CountryCode
type, which will be a union of all the country codes.Given our requirements, the generated TypeScript file will look like this:
// This file is generated by @radzionkit/utils/countries/codegen/generateCountries.ts
export const countryNameRecord = {
AF: "Afghanistan",
AL: "Albania",
// ...
ZM: "Zambia",
ZW: "Zimbabwe",
} as const
export type CountryCode = keyof typeof countryNameRecord
export const countryCodes = Object.keys(countryNameRecord) as CountryCode[]
Our workspace is structured as a monorepo. Within this setup, the countries
data resides in the utils
package, specifically under the countries
directory. The generateCountries
script from the codegen
directory produces an index.ts
file. To execute this script, we can either use the command npx tsx countries/codegen/generateCountries
directly from the terminal or incorporate it into the package.json
scripts section for ease of use. This allows you to simply run yarn generateCountries
whenever you need to generate or update the country data.
"scripts": {
"generateCountries": "npx tsx countries/codegen/generateCountries"
},
In the generateCountries
file, we read from the countries.json
file. This file contains country codes and names and is situated in the same directory as the script. We first organize blocks of code and separate them with two newline characters. The initial block establishes the countryNameRecord
object as a constant. The subsequent block determines the CountryCode
type, and the final block assembles the countryCodes
array. In the end, we invoke the createTsFile
function, tasked with writing the file.
import path from "path"
import fs from "fs"
import { createTsFile } from "@radzionkit/codegen/utils/createTsFile"
const generateCountries = async () => {
const countryNameRecord = JSON.parse(
fs.readFileSync(path.resolve(__dirname, "./countries.json"), "utf8")
)
const content = [
`export const countryNameRecord = ${JSON.stringify(
countryNameRecord
)} as const`,
`export type CountryCode = keyof typeof countryNameRecord`,
`export const countryCodes = Object.keys(countryNameRecord) as CountryCode[]`,
].join("\n\n")
await createTsFile({
directory: path.resolve(__dirname, "../"),
fileName: "index",
content,
generatedBy: "@radzionkit/utils/countries/codegen/generateCountries.ts",
})
}
generateCountries()
The createTsFile
function is designed for reusability across multiple packages in our monorepo. As a result, we've isolated it within a dedicated package named codegen
. This function accepts several parameters:
extension
- specifies the file extension, defaulting to ts
.directory
- indicates the directory where the file will be stored.fileName
- names the file.content
- outlines the content for the file.generatedBy
- records the script responsible for generating the file.Upon execution, the function first checks and creates the necessary directory if it's missing. Subsequently, it retrieves the Prettier configuration from the project's root to ensure the content is formatted consistently with the existing codebase. A comment, indicating the source of the generated code, is added at the beginning of the file. Ultimately, the content is written into the designated directory.
import fs from "fs"
import { format, resolveConfig } from "prettier"
import path from "path"
interface CreateTsFileParams {
extension?: "ts" | "tsx"
directory: string
fileName: string
generatedBy: string
content: string
}
export const createTsFile = async ({
extension = "ts",
directory,
fileName,
generatedBy,
content,
}: CreateTsFileParams) => {
fs.mkdirSync(directory, { recursive: true })
const configPath = path.resolve(__dirname, "../../.prettierrc")
const config = await resolveConfig(configPath)
const formattedContent = await format(
[`// This file is generated by ${generatedBy}`, content].join("\n"),
{
...config,
parser: "typescript",
}
)
const tsFilePath = `${directory}/${fileName}.${extension}`
fs.writeFileSync(tsFilePath, formattedContent)
}
Let's move on to generating components, a task that may seem complex but becomes more manageable when broken down. First, we'll create individual components for each country's flag. Following that, we'll design a master component that renders the appropriate flag based on a given country code. Here's an example of the component for the Italian flag:
// This file is generated by @radzionkit/ui/country/codegen/generateFlags.ts
import { SvgIconProps } from "@radzionkit/ui/icons/SvgIconProps"
const ItFlag = (props: SvgIconProps) => (
<svg
{...props}
width="1em"
height="0.75em"
xmlns="http://www.w3.org/2000/svg"
id="flag-icons-it"
viewBox="0 0 640 480"
{...props}
>
<g fillRule="evenodd" strokeWidth="1pt">
<path fill="#fff" d="M0 0h640v480H0z" />
<path fill="#009246" d="M0 0h213.3v480H0z" />
<path fill="#ce2b37" d="M426.7 0H640v480H426.7z" />
</g>
</svg>
)
export default ItFlag
In our system, when creating SVG icon components, we leverage the SvgIconProps
type from the @radzionkit/ui/icons
directory. This type is a modified version of React's SVGProps
, with the "ref" property excluded.
import { SVGProps } from "react"
export type SvgIconProps = Omit<SVGProps<SVGSVGElement>, "ref">
Before diving into the script, it's crucial to understand a foundational function: the one that converts an SVG string into a React component string. The heavy lifting within the svgToReact
function is primarily executed by the transform
function from the @svgr/core
package. However, additional processing is required to finalize the component.
Our aim is to set the component size using the font-size
attribute, which can be inherited from its parent. We also need to ensure that the SVG does not exceed its boundaries, meaning the larger dimension of the SVG should match the font-size
. To realize this, we first extract the SVG's size from its viewBox
attribute. Following that, the normalizeToMaxDimension
function from RadzionKit helps us determine the dimensions in em units.
Although SVGR yields a component, our goal is to enhance it. We want this component to accept the SvgIconProps
we previously discussed. Hence, we utilize a regular expression to isolate the SVG content and then embed it within our customized component.
import { Dimensions } from "@radzionkit/utils/entities/Dimensions"
import { normalizeToMaxDimension } from "@radzionkit/utils/normalizeToMaxDimension"
import { shouldBeDefined } from "@radzionkit/utils/shouldBeDefined"
import { transform } from "@svgr/core"
const getSvgDimensions = (svg: string): Dimensions => {
const viewBoxMatch = svg.match(/viewBox="([^"]+)"/)
if (!viewBoxMatch) {
throw new Error("SVG does not have a viewBox attribute.")
}
const [, viewBoxValues] = viewBoxMatch
const [, , width, height] = viewBoxValues.split(" ").map(parseFloat)
return { width, height }
}
const extractSvg = (input: string) => {
const regex = /<svg[\s\S]*?<\/svg>/
const match = input.match(regex) || undefined
return shouldBeDefined(match)[0]
}
interface SvgToReactParams {
svg: string
componentName: string
}
export const svgToReact = async ({ svg, componentName }: SvgToReactParams) => {
const svgComponent = await transform(
svg,
{
plugins: ["@svgr/plugin-jsx"],
},
{ componentName: "MyComponent" }
)
const { width, height } = normalizeToMaxDimension(getSvgDimensions(svg))
const cleanedSvg = extractSvg(svgComponent)
.replace(/\s*width="[^"]*"/g, "")
.replace(/\s*height="[^"]*"/g, "")
.replace("svg", `svg width="${width}em" height="${height}em"`)
.replace("svg", "svg {...props}")
return [
`import { SvgIconProps } from '@radzionkit/ui/icons/SvgIconProps'`,
`const ${componentName} = (props: SvgIconProps) => ${cleanedSvg}`,
`export default ${componentName}`,
].join("\n\n")
}
Having established the essential svgToReact
function, our attention can now shift to the generateFlags
script. Situated in the countries/codegen
directory, it bears a resemblance to the generateCountries
function we explored earlier, but is housed within the ui
package.
import { CountryCode, countryCodes } from "@radzionkit/utils/countries"
import { makeRecord } from "@radzionkit/utils/makeRecord"
import fs from "fs"
import path from "path"
import { capitalizeFirstLetter } from "@radzionkit/utils/capitalizeFirstLetter"
import { createTsFile } from "@radzionkit/codegen/utils/createTsFile"
import { svgToReact } from "../../codegen/svgToReact"
const getSvgFlagPath = (code: CountryCode) =>
path.resolve(__dirname, "./flags", `${code.toLowerCase()}.svg`)
const flagsPath = path.resolve(__dirname, "../flags")
const getFlagComponentName = (code: CountryCode) =>
`${capitalizeFirstLetter(code.toLowerCase())}Flag`
const generateFlags = async () => {
const svgRecord = makeRecord(countryCodes, (code) =>
fs.readFileSync(getSvgFlagPath(code), "utf8")
)
const generatedBy = "@radzionkit/ui/country/codegen/generateFlags.ts"
await Promise.all(
countryCodes.map(async (code) => {
const svg = svgRecord[code]
const content = await svgToReact({
svg,
componentName: getFlagComponentName(code),
})
return createTsFile({
extension: "tsx",
directory: flagsPath,
fileName: getFlagComponentName(code as CountryCode),
content,
generatedBy,
})
})
)
// ...
}
generateFlags()
To start, we need to create a mapping between country codes and their associated SVGs. This can be achieved with the makeRecord
function from RadzionKit. This utility aids in constructing the record by accepting an array of keys and a function which, given a key, outputs its corresponding value.
export const makeRecord = <T extends string, V>(
keys: T[],
getValue: (key: T) => V
) => {
const record: Record<T, V> = {} as Record<T, V>
keys.forEach((key) => {
record[key] = getValue(key)
})
return record
}
We loop through the country codes and create a component for each. We use the svgToReact
function to transform the SVG into a React component. After that, the createTsFile
function writes the component to the file system.
Next, we need to create a dynamic component that displays the appropriate flag based on the given country code. This component should have a reference to all country codes and their corresponding components. However, to optimize performance, we shouldn't load every component simultaneously. Using the next/dynamic
package, we can load them as needed. If a specific flag component isn't immediately available, we'd like to display CountryFlagFallback
. But here's a challenge: we cannot forward the props passed to the dynamically loaded component to the fallback one. To address this, we leverage React's context.
// This file is generated by @radzionkit/ui/country/codegen/generateFlags.ts
import dynamic from "next/dynamic"
import { SvgIconProps } from "../../icons/SvgIconProps"
import { ComponentType } from "react"
import { CountryCode } from "@radzionkit/utils/countries"
import {
CountryFlagDynamicFallback,
CountryFlagFallbackPropsProvider,
} from "../CountryFlagDynamicFallback"
const countryFlagRecord: Record<CountryCode, ComponentType<SvgIconProps>> = {
AF: dynamic(() => import("./AfFlag"), {
ssr: false,
loading: () => <CountryFlagDynamicFallback />,
}),
AL: dynamic(() => import("./AlFlag"), {
ssr: false,
loading: () => <CountryFlagDynamicFallback />,
}),
// ...
ZM: dynamic(() => import("./ZmFlag"), {
ssr: false,
loading: () => <CountryFlagDynamicFallback />,
}),
ZW: dynamic(() => import("./ZwFlag"), {
ssr: false,
loading: () => <CountryFlagDynamicFallback />,
}),
}
interface CountryFlagProps extends SvgIconProps {
code: CountryCode
}
export const CountryFlag = (props: CountryFlagProps) => {
const Component = countryFlagRecord[props.code]
return (
<CountryFlagFallbackPropsProvider value={props}>
<Component {...props} />
</CountryFlagFallbackPropsProvider>
)
}
export default CountryFlag
A common scenario in React development is having a provider that only deals with a single value. To simplify this, I've developed a helper named getValueProviderSetup
. This utility streamlines the creation of such providers, and you can view it in the RadzionKit repository.
import { getValueProviderSetup } from "../state/getValueProviderSetup"
import {
CountryFlagFallback,
CountryFlagFallbackProps,
} from "./CountryFlagFallback"
const {
useValue: useCountryFlagFallbackProps,
provider: CountryFlagFallbackPropsProvider,
} = getValueProviderSetup<CountryFlagFallbackProps>("CountryFlagFallbackProps")
export const CountryFlagDynamicFallback = () => {
const props = useCountryFlagFallbackProps()
return <CountryFlagFallback {...props} />
}
export { CountryFlagFallbackPropsProvider }
The fallback component is designed to display the country code centered within a rectangle. This rectangle maintains the same aspect ratio as our flag icon, ensuring there's no visual shift or jump when the actual flag loads.
import { CountryCode } from "@radzionkit/utils/countries"
import styled from "styled-components"
import { getColor } from "../theme/getters"
import { CountryFlagFrame } from "./CountryFlagFrame"
import { SvgIconProps } from "../icons/SvgIconProps"
export interface CountryFlagFallbackProps extends SvgIconProps {
code: CountryCode
}
const Container = styled(CountryFlagFrame)`
text {
fill: ${getColor("textSupporting")};
font-size: 0.4em;
}
`
export const CountryFlagFallback = ({
code,
...props
}: CountryFlagFallbackProps) => (
<Container {...props}>
<text x="50%" y="55%" textAnchor="middle" dominantBaseline="middle">
{code}
</text>
</Container>
)
Let's circle back to our primary focus, the generateFlags
script. First, we outline all necessary imports. Subsequently, for each country code, we dynamically generate a component. Finally, we craft the CountryFlag
component, which leverages the CountryFlagFallbackPropsProvider
to relay the props to our fallback component.
const imports = [
`import dynamic from 'next/dynamic'`,
`import { SvgIconProps } from '../../icons/SvgIconProps'`,
`import { ComponentType } from 'react'`,
`import { CountryCode } from '@radzionkit/utils/countries'`,
`import { CountryFlagDynamicFallback, CountryFlagFallbackPropsProvider } from '../CountryFlagDynamicFallback'`,
].join("\n")
const countryFlagComponentRecord = makeRecord(countryCodes, (code) => {
const componentName = getFlagComponentName(code)
return `dynamic(() => import('./${componentName}'), { ssr: false, loading: () => <CountryFlagDynamicFallback /> })`
})
const content = [
imports,
`const countryFlagRecord: Record<CountryCode, ComponentType<SvgIconProps>> = {
${Object.entries(countryFlagComponentRecord)
.map(([key, value]) => {
return `${key}: ${value}`
})
.join(",")}
}`,
`interface CountryFlagProps extends SvgIconProps { code: CountryCode }`,
`export const CountryFlag = (props: CountryFlagProps) => {
const Component = countryFlagRecord[props.code]
return (
<CountryFlagFallbackPropsProvider value={props}>
<Component {...props} />
</CountryFlagFallbackPropsProvider>
)
}`,
`export default CountryFlag`,
].join("\n\n")
await createTsFile({
extension: "tsx",
directory: flagsPath,
fileName: "CountryFlag",
content,
generatedBy,
})
/flag
PageTo showcase our solution, there's a /flag
page on RadzionKit. This page displays a comprehensive list of countries alongside their flags. The TabNavigation
component facilitates switching between viewing flags as SVGs or emojis. We use the countryCodes
list to loop through all countries, and the countryNameRecord
assists in displaying each country's name.
import { DemoPage } from "components/DemoPage"
import { useState } from "react"
import { TabNavigation } from "@radzionkit/ui/navigation/TabNavigation"
import { capitalizeFirstLetter } from "@radzionkit/utils/capitalizeFirstLetter"
import { HStack, VStack } from "@radzionkit/ui/layout/Stack"
import { countryCodes, countryNameRecord } from "@radzionkit/utils/countries"
import { Match } from "@radzionkit/ui/base/Match"
import { Text } from "@radzionkit/ui/text"
import { CountryFlag } from "@radzionkit/ui/countries/flags/CountryFlag"
import { CountryFlagEmoji } from "@radzionkit/ui/countries/CountryFlagEmoji"
import { makeDemoPage } from "layout/makeDemoPage"
import { SameWidthChildrenRow } from "@radzionkit/ui/layout/SameWidthChildrenRow"
import { IconWrapper } from "@radzionkit/ui/icons/IconWrapper"
const views = ["svg", "emoji"] as const
type View = (typeof views)[number]
export default makeDemoPage(() => {
const [activeView, setActiveView] = useState<View>("svg")
return (
<DemoPage youtubeVideoId="s3ve27fqORk" title="Country flag">
<VStack fullWidth gap={40}>
<TabNavigation
views={views}
getViewName={capitalizeFirstLetter}
activeView={activeView}
onSelect={setActiveView}
groupName="flags"
/>
<SameWidthChildrenRow childrenWidth={240} gap={20}>
{countryCodes.map((code) => (
<HStack key={code} alignItems="center" gap={12}>
<Text height="small" size={24} color="contrast">
<Match
value={activeView}
emoji={() => <CountryFlagEmoji code={code} />}
svg={() => (
<IconWrapper style={{ borderRadius: 4 }}>
<CountryFlag code={code} />
</IconWrapper>
)}
/>
</Text>
<Text size={14}>{countryNameRecord[code]}</Text>
</HStack>
))}
</SameWidthChildrenRow>
</VStack>
</DemoPage>
)
})
The Match
component from RadzionKit ensures that the appropriate content is rendered based on the active tab selection. To bestow a border radius upon the CountryFlag
, we nest it inside the IconWrapper
component. This component, a styled span
element, is designed to fit its content perfectly, and it conceals any overflow.
import styled from "styled-components"
export const IconWrapper = styled.span`
display: inline-flex;
justify-content: center;
align-items: center;
line-height: 1;
width: fit-content;
height: fit-content;
overflow: hidden;
`
While it might have been simpler to use emojis for flags, they come with design considerations, such as the waving shape on Mac, which might not be suitable for all UIs. For those seeking a flag representation using emojis, the provided function transforms a country code into its corresponding flag emoji.
import { CountryCode } from "."
export const getCountryFlagEmoji = (countryCode: CountryCode) => {
const codePoints = countryCode
.toUpperCase()
.split("")
.map((char) => 127397 + char.charCodeAt(0))
return String.fromCodePoint(...codePoints)
}