├── .changeset ├── README.md └── config.json ├── .github └── workflows │ ├── main.yml │ └── publish.yml ├── .gitignore ├── .nvmrc ├── .vscode └── launch.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── babel.config.js ├── docs └── cover.png ├── jest.config.ts ├── jest.config.tsd.js ├── package-lock.json ├── package.json ├── src ├── __tests__ │ └── stringInterpolation.test.ts ├── __typetests__ │ └── stringInterpolation.test.tsx └── index.ts └── tsconfig.json /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - "**" 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: actions/setup-node@v3 13 | with: 14 | node-version: 18 15 | cache: "npm" 16 | - run: npm ci 17 | - run: npm run lint && npm run test && npm run build 18 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | jobs: 11 | publish: 12 | name: Publish 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout Repo 16 | uses: actions/checkout@v3 17 | 18 | - name: Setup Node.js 18 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: 18 22 | 23 | - name: Install Dependencies 24 | run: npm ci 25 | 26 | - name: Create Release Pull Request or Publish 27 | id: changesets 28 | uses: changesets/action@v1 29 | with: 30 | # This expects you to have a script called release which does a build for your packages and calls changeset publish 31 | publish: npm run release 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18 -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | // https://github.com/microsoft/vscode-recipes/tree/main/debugging-jest-tests 9 | // https://www.youtube.com/watch?v=_usf3xQ7wys 10 | "type": "node", 11 | "request": "launch", 12 | "name": "Jest Current File", 13 | "program": "${workspaceFolder}/node_modules/.bin/jest", 14 | "args": [ 15 | "--runTestsByPath", 16 | "${relativeFile}", 17 | "--config", 18 | "jest.config.ts" 19 | ], 20 | "console": "integratedTerminal", 21 | "internalConsoleOptions": "neverOpen" 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # typed-string-interpolation 2 | 3 | ## 0.0.9 4 | 5 | ### Patch Changes 6 | 7 | - f54f580: Throw Error objects 8 | 9 | ## 0.0.8 10 | 11 | ### Patch Changes 12 | 13 | - 21fcfe9: Polyfill for String.matchAll 14 | 15 | ## 0.0.7 16 | 17 | ### Patch Changes 18 | 19 | - 4b9e63d: minification 20 | 21 | ## 0.0.6 22 | 23 | ### Patch Changes 24 | 25 | - 0c76def: Add concurrently and refined README 26 | 27 | ## 0.0.5 28 | 29 | ### Patch Changes 30 | 31 | - a04cb7e: Refine readme 32 | 33 | ## 0.0.4 34 | 35 | ### Patch Changes 36 | 37 | - 9ec3520: More additions to readme and package.json 38 | 39 | ## 0.0.3 40 | 41 | ### Patch Changes 42 | 43 | - 4f08b1b: Finalizing package 44 | 45 | ## 0.0.2 46 | 47 | ### Patch Changes 48 | 49 | - 32c3d71: Initial commit 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2023 Mikko Vänskä 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `typed-string-interpolation` 2 | 3 | [String interpolation](https://en.wikipedia.org/wiki/String_interpolation) for `TypeScript` with correct return types based on passed in variables. 4 | 5 | ![Library in use with React](docs/cover.png) 6 | 7 | > Library used within a React app. Note that the library itself is framework agnostic and could be used in any TypeScript/JavaScript app. 8 | 9 | ## Main features 10 | 11 | - Replaces variables within a string with passed in variables 12 | - Sanity checks that correct variables were passed in 13 | - Returns the correct type based on passed in variable substitutions 14 | - Options to customize return, pattern matching and sanity checking 15 | - Both ES Module and CommonJS distributions available. Use anywhere! 16 | - Tiny footprint: 17 | - ES Module: `379B` (`533B` unpacked) 18 | - CommonJS: `612B` (`1.03kB` unpacked) 19 | 20 | ## Motivation 21 | 22 | String interpolation/variable substitution (i.e. injecting variables within text) is a really common operation when building single and multilingual applications alike. Existing string interpolation utilities within the most used `i18n` / `l10n` packages like `i18next` and `formatjs` come with massive overhead while lacking proper TypeScript infer support for the interpolation operation. 23 | 24 | This utility aims to provide a high quality string interpolation "primitive" to use as is or within other localization frameworks and tooling. 25 | 26 | ## Getting started 27 | 28 | Easiest way to get started is to play around with a [React example sandbox](https://codesandbox.io/p/sandbox/typed-string-interpolation-react-example-slpjgp?file=%2Fsrc%2Fmain.tsx). 29 | 30 | ### Install 31 | 32 | ```bash 33 | npm i typed-string-interpolation 34 | ``` 35 | 36 | ### Usage 37 | 38 | ```ts 39 | // ES module 40 | import { stringInterpolation } from "typed-string-interpolation" 41 | // CommonJS 42 | const { stringInterpolation } = require("typed-string-interpolation") 43 | ``` 44 | 45 | Returns a `string` when the result can be joined into a string. 46 | 47 | ```ts 48 | stringInterpolation("You have {{n}} messages", { 49 | n: 3, 50 | }) // "You have 3 messages" 51 | ``` 52 | 53 | Returns an array when the result can't be joined into a `string`. This makes it really easy to use the utility with libraries like `react` or anything else. 54 | 55 | ```tsx 56 | stringInterpolation("You have {{n}} messages", { 57 | n: 3, 58 | }) // ["You have ", 3, " messages"] 59 | ``` 60 | 61 | ## TypeScript support 62 | 63 | If the string can be joined you'll get back a `string` type. Otherwise a `union` type within an array is returned based on the passed in variables. 64 | 65 | ```ts 66 | stringInterpolation("You have {{n}} messages from {{person}}", { 67 | n: 3, 68 | person: "John", 69 | }) // : string 70 | ``` 71 | 72 | ```tsx 73 | stringInterpolation("You have {{n}} messages from {{person}}", { 74 | n: 3, 75 | person: "John", 76 | }) // : (JSX.Element | string)[] 77 | ``` 78 | 79 | ## Options 80 | 81 | Takes in an optional third parameter for options: 82 | 83 | ```js 84 | stringInterpolation(str, variables, options) 85 | ``` 86 | 87 | ```ts 88 | type Options = { 89 | raw?: boolean // default: false 90 | pattern?: RegExp // default: new RegExp(/\{{([^{]+)}}/g) 91 | sanity?: boolean // default: true 92 | } 93 | ``` 94 | 95 | `raw` 96 | 97 | Return the raw interpolation results without joining to string when you want full control for some reason. 98 | 99 | ```tsx 100 | stringInterpolation( 101 | "You have {{n}} messages from {{person}}", 102 | { 103 | n: 3, 104 | person: "John", 105 | }, 106 | { raw: true } 107 | ) // : (number | string)[] 108 | ``` 109 | 110 | `pattern` 111 | 112 | Provide your own `RegExp` pattern for variable matching. Must be defined as: 113 | 114 | ```ts 115 | pattern: new RegExp(/\{{([^{]+)}}/g) 116 | ``` 117 | 118 | `sanity` 119 | 120 | If you want to live dangerously, sanity checking can be turned off. 121 | 122 | ```ts 123 | { 124 | sanity: false 125 | } 126 | ``` 127 | 128 | Turning of sanity checking removes `throw` from: 129 | 130 | - empty string 131 | - string variables and passed in variables count mismatch 132 | - missing variables 133 | 134 | ## Contributing 135 | 136 | Easiest way to contribute is to open new issues for API suggestions and bugs. 137 | 138 | ### Contributing for a release 139 | 140 | Steps for contributing through a pull request: 141 | 142 | - Fork `main` on Github and clone fork locally 143 | - Install dependencies 144 | - `npm ci` 145 | - Make changes while running tests in watch mode 146 | - `npm run test:unit:all:watch` 147 | - This project has a `.vscode/launch.json` file containing configuration for running Jest tests with the VSCode debugger which makes it simple to step through logic excecution. Steps to use VSCode debugger: 148 | - Add a breakpoint to the source code 149 | - Open a Jest unit test file (`*.test.ts`) 150 | - Go to the VSCode debugger Tab (`shift` + `command` + `D` on MacOS) and select "Jest Current File" or optionally start the debug session from the command line (`shift` + `command` + `P` on MacOS) and type "Debug: Start debugging" 151 | - VSCode should open a new terminal window and attach the Jest instance to the debugger 152 | - Debugger should stop on the defined breakpoint in the source code 153 | - Once all changes are complete, create a new release with [changesets](https://github.com/changesets/changesets) 154 | - `npm run create-release` 155 | - Commit and push changes to fork 156 | - Open a pull request against the fork 157 | - If the PR needs changes before a merge to `main` can be made, push more changes to the fork until the PR is approved 158 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ["@babel/preset-env", { targets: { node: "current" } }], 4 | "@babel/preset-typescript", 5 | ], 6 | } 7 | -------------------------------------------------------------------------------- /docs/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vanska/typed-string-interpolation/d3c4a0006bad0ab9a82b4992b5e6bf19e89f6ad8/docs/cover.png -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "jest" 2 | 3 | const config: Config = { 4 | verbose: true, 5 | testMatch: ["/src/__tests__/*.ts"], 6 | } 7 | 8 | export default config 9 | -------------------------------------------------------------------------------- /jest.config.tsd.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | displayName: { 3 | color: "blue", 4 | name: "types", 5 | }, 6 | runner: "jest-runner-tsd", 7 | testMatch: ["**/__typetests__/*.test.ts*"], 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typed-string-interpolation", 3 | "description": "String interpolation with correct return type based on passed variable substitutions", 4 | "author": "Mikko Vänskä", 5 | "repository": "https://github.com/vanska/typed-string-interpolation", 6 | "license": "MIT", 7 | "version": "0.0.9", 8 | "main": "dist/index.js", 9 | "module": "dist/index.mjs", 10 | "types": "dist/index.d.ts", 11 | "files": [ 12 | "/dist" 13 | ], 14 | "private": false, 15 | "keywords": [ 16 | "string", 17 | "interpolation", 18 | "substitution", 19 | "injection", 20 | "replace", 21 | "inject", 22 | "substitute", 23 | "interpolate", 24 | "variable", 25 | "variables", 26 | "type", 27 | "typed", 28 | "infer", 29 | "type-safe", 30 | "i18n", 31 | "l10n", 32 | "i18next", 33 | "formatjs" 34 | ], 35 | "scripts": { 36 | "lint": "tsc", 37 | "test:unit": "jest", 38 | "test:unit:types": "jest -c jest.config.tsd.js", 39 | "test:unit:watch": "jest --watch", 40 | "test:unit:types:watch": "jest -c jest.config.tsd.js --watch", 41 | "test:unit:all:watch": "concurrently --raw \"npm run test:unit:watch\" \"npm run test:unit:types:watch\"", 42 | "test": "npm run test:unit && npm run test:unit:types", 43 | "build": "tsup src/index.ts --format cjs,esm --dts --minify", 44 | "release": "npm run build && changeset publish", 45 | "create-release": "changeset" 46 | }, 47 | "devDependencies": { 48 | "@babel/core": "^7.20.12", 49 | "@babel/preset-env": "^7.20.2", 50 | "@babel/preset-typescript": "^7.18.6", 51 | "@changesets/cli": "^2.26.0", 52 | "@tsd/typescript": "^4.9.4", 53 | "@types/jest": "^29.2.5", 54 | "babel-jest": "^29.3.1", 55 | "concurrently": "^8.2.0", 56 | "jest": "^29.3.1", 57 | "jest-runner-tsd": "^4.0.0", 58 | "ts-node": "^10.9.1", 59 | "tsd-lite": "^0.6.0", 60 | "tsup": "^6.5.0", 61 | "typescript": "^4.9.4" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/__tests__/stringInterpolation.test.ts: -------------------------------------------------------------------------------- 1 | import { stringInterpolation } from "../index" 2 | 3 | describe("stringInterpolation()", () => { 4 | test("Empty", () => { 5 | expect(() => { 6 | stringInterpolation("", { 7 | world: "world", 8 | }) 9 | }).toThrow("Empty string") 10 | }) 11 | test("Incorrect variable count", () => { 12 | expect(() => { 13 | stringInterpolation("Hello {{world}}", { 14 | world: "world with varialbe", 15 | extraVariable: "this is unnecessary", 16 | }) 17 | }).toThrow("Variable count mismatch") 18 | }) 19 | test("Variable not found", () => { 20 | expect(() => 21 | stringInterpolation("Hello {{world}}", { 22 | wrongVariable: "world", 23 | }) 24 | ).toThrow("Variable 'world' not found") 25 | }) 26 | test("Interpolate single variable", () => { 27 | expect( 28 | stringInterpolation("Hello {{world}}", { 29 | world: "world with variable", 30 | }) 31 | ).toBe("Hello world with variable") 32 | }) 33 | test("Interpolate single variable and return raw result with passed in option", () => { 34 | expect( 35 | stringInterpolation( 36 | "Hello {{world}}", 37 | { 38 | world: "world with variable", 39 | }, 40 | { raw: true } 41 | ) 42 | ).toStrictEqual(["Hello ", "world with variable"]) 43 | }) 44 | test("Interpolate two variables", () => { 45 | expect( 46 | stringInterpolation("Hello {{world}} and {{anotherVariable}}", { 47 | world: "world with variable", 48 | anotherVariable: "another variable", 49 | }) 50 | ).toBe("Hello world with variable and another variable") 51 | }) 52 | test("Interpolation variable contains a function", () => { 53 | expect( 54 | stringInterpolation("Hello {{world}} and {{anotherVariable}}", { 55 | world: "world with variable", 56 | anotherVariable: () => "another variable", 57 | }) 58 | ).toStrictEqual([ 59 | "Hello ", 60 | "world with variable", 61 | " and ", 62 | expect.any(Function), 63 | ]) 64 | }) 65 | }) 66 | -------------------------------------------------------------------------------- /src/__typetests__/stringInterpolation.test.tsx: -------------------------------------------------------------------------------- 1 | import { expectType } from "tsd-lite" 2 | import { stringInterpolation } from "../index" 3 | 4 | // ~~~~~~~~~~~~~~ TYPESCRIPT TYPE TESTING ~~~~~~~~~~~~~~ 5 | 6 | expectType( 7 | stringInterpolation("hello {{one}}", { 8 | one: "one", 9 | }) 10 | ) 11 | 12 | expectType( 13 | stringInterpolation("hello {{one}}", { 14 | one: "one", 15 | }) 16 | ) 17 | 18 | expectType( 19 | stringInterpolation( 20 | "hello {{one}}", 21 | { 22 | one: "one", 23 | }, 24 | { raw: true } 25 | ) 26 | ) 27 | 28 | expectType( 29 | stringInterpolation("hello {{one}} {{two}}", { 30 | one: "one", 31 | two: 2, 32 | }) 33 | ) 34 | 35 | expectType( 36 | stringInterpolation("hello {{one}}", { 37 | one: 2, 38 | }) 39 | ) 40 | 41 | expectType< 42 | ( 43 | | string 44 | | number 45 | | { 46 | type: string 47 | props: { 48 | className: string 49 | children: string 50 | } 51 | } 52 | )[] 53 | >( 54 | stringInterpolation("hello {{one}} {{two}} {{three}}", { 55 | one: "one", 56 | two: 2, 57 | three: { 58 | type: "span", 59 | props: { 60 | className: "bold", 61 | children: "one", 62 | }, 63 | }, 64 | }) 65 | ) 66 | 67 | expectType<(string | number | Date)[]>( 68 | stringInterpolation("hello {{one}} {{two}} {{three}}", { 69 | one: "one", 70 | two: 2, 71 | three: new Date(), 72 | }) 73 | ) 74 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | type StringInterpolationOptions = { 2 | pattern?: RegExp 3 | sanity?: boolean 4 | raw?: Raw 5 | } 6 | 7 | type StringInterpolationReturn = Exclude< 8 | VariableValue, 9 | string | number 10 | > extends never 11 | ? Extract extends true 12 | ? (string | VariableValue)[] 13 | : string 14 | : (string | VariableValue)[] 15 | 16 | /** 17 | * String.matchAll polyfill 18 | * Used because no support in Safari <= 12 19 | * @see https://caniuse.com/mdn-javascript_builtins_string_matchall 20 | * @see https://stackoverflow.com/questions/58003217/how-to-use-the-string-prototype-matchall-polyfill 21 | */ 22 | function matchAllPolyfill(string: string, pattern: RegExp) { 23 | let match 24 | const matches = [] 25 | 26 | while ((match = pattern.exec(string))) matches.push(match) 27 | 28 | return matches 29 | } 30 | 31 | /** 32 | * Takes in a string containing variables and an object containing variables for interpolation. Accepts options. 33 | * 34 | * @example 35 | * 36 | * stringInterpolation("You have {{n}} messages", { 37 | n: 3, 38 | }) 39 | 40 | stringInterpolation("You have {{n}} messages from {{person}}", { 41 | n: 3, 42 | person: "John", 43 | }) 44 | */ 45 | export function stringInterpolation< 46 | VariableValue extends any, 47 | OptionRaw extends boolean | undefined 48 | >( 49 | string: string, 50 | variables: Record, 51 | { 52 | pattern = new RegExp(/\{{([^{]+)}}/g), 53 | sanity = true, 54 | raw: rawOutput = false, 55 | }: StringInterpolationOptions = {} 56 | ): StringInterpolationReturn { 57 | if (!string && sanity) throw new Error("Empty string") 58 | 59 | // Find all variables within string 60 | const stringVariables = matchAllPolyfill(string, pattern) 61 | 62 | // No variables => no need to interpolate 63 | if (!stringVariables[0]) 64 | return string as StringInterpolationReturn 65 | 66 | if (sanity) { 67 | // Sanity check string variables <-> passed in variables count 68 | const variableKeys = Object.keys(variables) 69 | // Checks whether variables parsed from string exist in passed argument 70 | if (stringVariables.length !== variableKeys.length) 71 | throw new Error("Variable count mismatch") 72 | for (const regExpMatchArray of stringVariables) { 73 | const variableKeyInString = regExpMatchArray[1] 74 | if (variableKeyInString && !variableKeys.includes(variableKeyInString)) 75 | throw new Error(`Variable '${variableKeyInString}' not found`) 76 | } 77 | } 78 | 79 | // Create raw interpolation result 80 | const rawInterpolation = string 81 | .split(pattern) 82 | // Trim empty string from array end (Could propably be done with regex as well) 83 | .filter(Boolean) 84 | // Match parsed variables with passed in variables 85 | .map((splitItem) => { 86 | return variables[splitItem] ? variables[splitItem] : splitItem 87 | }) 88 | 89 | // Checks if raw interpolation can be joined or not. 90 | // i.e. avoid printing [object Object | Array | Function | ...] within returned string. 91 | const canJoin = !rawInterpolation.filter( 92 | (i) => typeof i !== "string" && typeof i !== "number" 93 | )[0] 94 | 95 | if (canJoin && !rawOutput) 96 | return rawInterpolation.join("") as StringInterpolationReturn< 97 | VariableValue, 98 | OptionRaw 99 | > 100 | 101 | return rawInterpolation as StringInterpolationReturn 102 | } 103 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 4 | "module": "commonjs" /* Specify what module code is generated. */, 5 | "noEmit": true /* Disable emitting files from a compilation. */, 6 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, 7 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 8 | "strict": true /* Enable all strict type-checking options. */, 9 | "jsx": "react" 10 | }, 11 | "exclude": ["dist", "node_modules"] 12 | } 13 | --------------------------------------------------------------------------------