Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make types of available keys more strict and configurable through generics #279

Merged
merged 15 commits into from
Aug 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 54 additions & 18 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,32 +40,47 @@ Why no object support? [Read here](https://github.com/dcastil/tailwind-merge/dis
## `getDefaultConfig`

```ts
function getDefaultConfig(): Config
function getDefaultConfig(): satisfies Config<DefaultClassGroupIds, DefaultThemeGroupIds>
```

Function which returns the default config used by tailwind-merge. The tailwind-merge config is different from the Tailwind config. It is optimized for small bundle size and fast runtime performance because it is expected to run in the browser.

## `fromTheme`

```ts
function fromTheme(key: string): ThemeGetter
function fromTheme<
AdditionalThemeGroupIds extends string = never,
DefaultThemeGroupIdsInner extends string = DefaultThemeGroupIds,
>(key: NoInfer<DefaultThemeGroupIdsInner | AdditionalThemeGroupIds>): ThemeGetter
```

Function to retrieve values from a theme scale, to be used in class groups.

`fromTheme` doesn't return the values from the theme scale, but rather another function which is used by tailwind-merge internally to retrieve the theme values. tailwind-merge can differentiate the theme getter function from a validator because it has a `isThemeGetter` property set to `true`.

It can be used like this:
When using TypeScript, the function only allows passing the default theme group IDs as the `key` argument. If you use custom theme group IDs, you need to pass them as the generic type argument `AdditionalThemeGroupIds`. In case you aren't using the default tailwind-merge config and use a different set of theme group IDs entirely, you can also pass them as the generic type argument `DefaultThemeGroupIdsInner`. If you want to allow any keys, you can call it as `fromTheme<string>('anything-goes-here')`.

`fromTheme` can be used like this:

```ts
extendTailwindMerge({
type AdditionalClassGroupIds = 'my-group' | 'my-group-x'
type AdditionalThemeGroupIds = 'my-scale'

extendTailwindMerge<AdditionalClassGroupIds, AdditionalThemeGroupIds>({
extend: {
theme: {
'my-scale': ['foo', 'bar'],
},
classGroups: {
'my-group': [{ 'my-group': [fromTheme('my-scale'), fromTheme('spacing')] }],
'my-group-x': [{ 'my-group-x': [fromTheme('my-scale')] }],
'my-group': [
{
'my-group': [
fromTheme<AdditionalThemeGroupIds>('my-scale'),
fromTheme('spacing'),
],
},
],
'my-group-x': [{ 'my-group-x': [fromTheme<AdditionalThemeGroupIds>('my-scale')] }],
},
},
})
Expand All @@ -74,11 +89,20 @@ extendTailwindMerge({
## `extendTailwindMerge`

```ts
function extendTailwindMerge(
configExtension: ConfigExtension,
...createConfig: ((config: Config) => Config)[]
function extendTailwindMerge<
AdditionalClassGroupIds extends string = never,
AdditionalThemeGroupIds extends string = never,
>(
configExtension: ConfigExtension<
DefaultClassGroupIds | AdditionalClassGroupIds,
DefaultThemeGroupIds | AdditionalThemeGroupIds
>,
...createConfig: ((config: GenericConfig) => GenericConfig)[]
): TailwindMerge
function extendTailwindMerge(...createConfig: ((config: Config) => Config)[]): TailwindMerge
function extendTailwindMerge<
AdditionalClassGroupIds extends string = never,
AdditionalThemeGroupIds extends string = never,
>(...createConfig: ((config: GenericConfig) => GenericConfig)[]): TailwindMerge
```

Function to create merge function with custom config which extends the default config. Use this if you use the default Tailwind config and just modified it in some places.
Expand All @@ -88,8 +112,13 @@ Function to create merge function with custom config which extends the default c

You provide it a `configExtension` object which gets [merged](#mergeconfigs) with the default config.

When using TypeScript and you use custom class group IDs or theme group IDs, you need to pass them as the generic type arguments `AdditionalClassGroupIds` and `AdditionalThemeGroupIds`. This is enforced to prevent accidental use of non-existing class group IDs accidentally. If you want to allow any custom keys without explicitly defining them, you can pass as `string` to both arguments.

```ts
const customTwMerge = extendTailwindMerge({
type AdditionalClassGroupIds = 'aspect-w' | 'aspect-h' | 'aspect-reset'
type AdditionalThemeGroupIds = never

const customTwMerge = extendTailwindMerge<AdditionalClassGroupIds, AdditionalThemeGroupIds>({
// ↓ Optional cache size
// Here we're disabling the cache
cacheSize: 0,
Expand Down Expand Up @@ -241,25 +270,32 @@ But don't merge configs like that. Use [`mergeConfigs`](#mergeconfigs) instead.
## `mergeConfigs`

```ts
function mergeConfigs(baseConfig: Config, configExtension: Partial<Config>): Config
function mergeConfigs<ClassGroupIds extends string, ThemeGroupIds extends string = never>(
baseConfig: GenericConfig,
configExtension: ConfigExtension<ClassGroupIds, ThemeGroupIds>,
): GenericConfig
```

Helper function to merge multiple tailwind-merge configs. Properties with the value `undefined` are skipped.

When using TypeScript, you need to pass a union of all class group IDs and theme group IDs used in `configExtension` as generic arguments to `mergeConfigs` or pass `string` to both arguments to allow any IDs.

```ts
const customTwMerge = createTailwindMerge(getDefaultConfig, (config) =>
mergeConfigs(config, {
mergeConfigs<'shadow' | 'animate' | 'prose'>(config, {
override: {
classGroups: {
// ↓ Overriding existing class group
shadow: [{ shadow: ['100', '200', '300', '400', '500'] }],
},
}
extend: {
// ↓ Adding value to existing class group
animate: ['animate-shimmer'],
// ↓ Adding new class group
prose: [{ prose: ['', validators.isTshirtSize] }],
classGroups: {
// ↓ Adding value to existing class group
animate: ['animate-shimmer'],
// ↓ Adding new class group
prose: [{ prose: ['', validators.isTshirtSize] }],
}
},
}),
)
Expand Down Expand Up @@ -310,7 +346,7 @@ A brief summary for each validator:
## `Config`

```ts
interface Config { … }
interface Config<ClassGroupIds extends string, ThemeGroupIds extends string> { … }
```

TypeScript type for config object. Useful if you want to build a `createConfig` function but don't want to define it inline in [`extendTailwindMerge`](#extendtailwindmerge) or [`createTailwindMerge`](#createtailwindmerge).
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ If you only need to slightly modify the default tailwind-merge config, [`extendT
```ts
import { extendTailwindMerge } from 'tailwind-merge'

const customTwMerge = extendTailwindMerge({
const customTwMerge = extendTailwindMerge<'foo' | 'bar' | 'baz'>({
// ↓ Override eleemnts from the default config
// It has the same shape as the `extend` object, so we're going to skip it here.
override: {},
Expand Down
2 changes: 1 addition & 1 deletion docs/writing-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Here is an example of how a plugin could look like:
import { mergeConfigs, validators, Config } from 'tailwind-merge'

export function withMagic(config: Config): Config {
return mergeConfigs(config, {
return mergeConfigs<'magic.my-group'>(config, {
extend: {
classGroups: {
'magic.my-group': [{ magic: [validators.isLength, 'wow'] }],
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ export { fromTheme } from './lib/from-theme'
export { mergeConfigs } from './lib/merge-configs'
export { twJoin, type ClassNameValue } from './lib/tw-join'
export { twMerge } from './lib/tw-merge'
export type { Config } from './lib/types'
export { type Config, type DefaultClassGroupIds, type DefaultThemeGroupIds } from './lib/types'
export * as validators from './lib/validators'
36 changes: 24 additions & 12 deletions src/lib/class-utils.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,28 @@
import { ClassGroup, ClassGroupId, ClassValidator, Config, ThemeGetter, ThemeObject } from './types'
import {
ClassGroup,
ClassValidator,
Config,
GenericClassGroupIds,
GenericConfig,
GenericThemeGroupIds,
ThemeGetter,
ThemeObject,
} from './types'

export interface ClassPartObject {
nextPart: Map<string, ClassPartObject>
validators: ClassValidatorObject[]
classGroupId?: ClassGroupId
classGroupId?: GenericClassGroupIds
}

interface ClassValidatorObject {
classGroupId: ClassGroupId
classGroupId: GenericClassGroupIds
validator: ClassValidator
}

const CLASS_PART_SEPARATOR = '-'

export function createClassUtils(config: Config) {
export function createClassUtils(config: GenericConfig) {
const classMap = createClassMap(config)
const { conflictingClassGroups, conflictingClassGroupModifiers = {} } = config

Expand All @@ -28,7 +37,10 @@ export function createClassUtils(config: Config) {
return getGroupRecursive(classParts, classMap) || getGroupIdForArbitraryProperty(className)
}

function getConflictingClassGroupIds(classGroupId: ClassGroupId, hasPostfixModifier: boolean) {
function getConflictingClassGroupIds(
classGroupId: GenericClassGroupIds,
hasPostfixModifier: boolean,
) {
const conflicts = conflictingClassGroups[classGroupId] || []

if (hasPostfixModifier && conflictingClassGroupModifiers[classGroupId]) {
Expand All @@ -47,7 +59,7 @@ export function createClassUtils(config: Config) {
function getGroupRecursive(
classParts: string[],
classPartObject: ClassPartObject,
): ClassGroupId | undefined {
): GenericClassGroupIds | undefined {
if (classParts.length === 0) {
return classPartObject.classGroupId
}
Expand Down Expand Up @@ -91,7 +103,7 @@ function getGroupIdForArbitraryProperty(className: string) {
/**
* Exported for testing only
*/
export function createClassMap(config: Config) {
export function createClassMap(config: Config<GenericClassGroupIds, GenericThemeGroupIds>) {
const { theme, prefix } = config
const classMap: ClassPartObject = {
nextPart: new Map<string, ClassPartObject>(),
Expand All @@ -111,10 +123,10 @@ export function createClassMap(config: Config) {
}

function processClassesRecursively(
classGroup: ClassGroup,
classGroup: ClassGroup<GenericThemeGroupIds>,
classPartObject: ClassPartObject,
classGroupId: ClassGroupId,
theme: ThemeObject,
classGroupId: GenericClassGroupIds,
theme: ThemeObject<GenericThemeGroupIds>,
) {
classGroup.forEach((classDefinition) => {
if (typeof classDefinition === 'string') {
Expand Down Expand Up @@ -176,9 +188,9 @@ function isThemeGetter(func: ClassValidator | ThemeGetter): func is ThemeGetter
}

function getPrefixedClassGroupEntries(
classGroupEntries: Array<[classGroupId: string, classGroup: ClassGroup]>,
classGroupEntries: Array<[classGroupId: string, classGroup: ClassGroup<GenericThemeGroupIds>]>,
prefix: string | undefined,
): Array<[classGroupId: string, classGroup: ClassGroup]> {
): Array<[classGroupId: string, classGroup: ClassGroup<GenericThemeGroupIds>]> {
if (!prefix) {
return classGroupEntries
}
Expand Down
4 changes: 2 additions & 2 deletions src/lib/config-utils.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { createClassUtils } from './class-utils'
import { createLruCache } from './lru-cache'
import { createSplitModifiers } from './modifier-utils'
import { Config } from './types'
import { GenericConfig } from './types'

export type ConfigUtils = ReturnType<typeof createConfigUtils>

export function createConfigUtils(config: Config) {
export function createConfigUtils(config: GenericConfig) {
return {
cache: createLruCache<string, string>(config.cacheSize),
splitModifiers: createSplitModifiers(config),
Expand Down
15 changes: 7 additions & 8 deletions src/lib/create-tailwind-merge.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,26 @@
import { createConfigUtils } from './config-utils'
import { mergeClassList } from './merge-classlist'
import { ClassNameValue, twJoin } from './tw-join'
import { Config } from './types'
import { GenericConfig } from './types'

type CreateConfigFirst = () => Config
type CreateConfigSubsequent = (config: Config) => Config
type CreateConfigFirst = () => GenericConfig
type CreateConfigSubsequent = (config: GenericConfig) => GenericConfig
type TailwindMerge = (...classLists: ClassNameValue[]) => string
type ConfigUtils = ReturnType<typeof createConfigUtils>

export function createTailwindMerge(
...createConfig: [CreateConfigFirst, ...CreateConfigSubsequent[]]
createConfigFirst: CreateConfigFirst,
...createConfigRest: CreateConfigSubsequent[]
): TailwindMerge {
let configUtils: ConfigUtils
let cacheGet: ConfigUtils['cache']['get']
let cacheSet: ConfigUtils['cache']['set']
let functionToCall = initTailwindMerge

function initTailwindMerge(classList: string) {
const [firstCreateConfig, ...restCreateConfig] = createConfig

const config = restCreateConfig.reduce(
const config = createConfigRest.reduce(
(previousConfig, createConfigCurrent) => createConfigCurrent(previousConfig),
firstCreateConfig(),
createConfigFirst() as GenericConfig,
)

configUtils = createConfigUtils(config)
Expand Down
4 changes: 2 additions & 2 deletions src/lib/default-config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { fromTheme } from './from-theme'
import { Config } from './types'
import { Config, DefaultClassGroupIds, DefaultThemeGroupIds } from './types'
import {
isAny,
isArbitraryLength,
Expand Down Expand Up @@ -1792,5 +1792,5 @@ export function getDefaultConfig() {
conflictingClassGroupModifiers: {
'font-size': ['leading'],
},
} as const satisfies Config
} as const satisfies Config<DefaultClassGroupIds, DefaultThemeGroupIds>
}
16 changes: 12 additions & 4 deletions src/lib/extend-tailwind-merge.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import { createTailwindMerge } from './create-tailwind-merge'
import { getDefaultConfig } from './default-config'
import { mergeConfigs } from './merge-configs'
import { Config, ConfigExtension } from './types'
import { ConfigExtension, DefaultClassGroupIds, DefaultThemeGroupIds, GenericConfig } from './types'

type CreateConfigSubsequent = (config: Config) => Config
type CreateConfigSubsequent = (config: GenericConfig) => GenericConfig

export function extendTailwindMerge(
configExtension: ConfigExtension | CreateConfigSubsequent,
export function extendTailwindMerge<
AdditionalClassGroupIds extends string = never,
AdditionalThemeGroupIds extends string = never,
>(
configExtension:
| ConfigExtension<
DefaultClassGroupIds | AdditionalClassGroupIds,
DefaultThemeGroupIds | AdditionalThemeGroupIds
>
| CreateConfigSubsequent,
...createConfig: CreateConfigSubsequent[]
) {
return typeof configExtension === 'function'
Expand Down
10 changes: 7 additions & 3 deletions src/lib/from-theme.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { ThemeGetter, ThemeObject } from './types'
import { DefaultThemeGroupIds, NoInfer, ThemeGetter, ThemeObject } from './types'

export function fromTheme(key: string): ThemeGetter {
const themeGetter = (theme: ThemeObject) => theme[key] || []
export function fromTheme<
AdditionalThemeGroupIds extends string = never,
DefaultThemeGroupIdsInner extends string = DefaultThemeGroupIds,
>(key: NoInfer<DefaultThemeGroupIdsInner | AdditionalThemeGroupIds>): ThemeGetter {
const themeGetter = (theme: ThemeObject<DefaultThemeGroupIdsInner | AdditionalThemeGroupIds>) =>
theme[key] || []

themeGetter.isThemeGetter = true as const

Expand Down
Loading