Skip to content

aleksei-berezkin/typique

Repository files navigation

Typique

Typique (pronounced /ti'pik/) is a framework- and bundler-agnostic, zero-runtime CSS-in-TS library powered by a TypeScript plugin. It generates readable, unique class and variable names directly as completion items in your editor. Styles exist only as types, so they vanish cleanly from your final build.

Completion demo

Completion Demo

What it looks like in code

import type { Css } from 'typique'
import { space } from './my-const'

const titleClass = 'title-1' satisfies Css<{
  paddingTop: `calc(2 * ${typeof space}px)`
  '& > code': {
    backgroundColor: '#eee'
  }
}>
How it works
  • The constant titleClass follows the configurable naming convention, which instructs Typique to provide completion items.
  • The value title-1 was suggested because title and title-0 are already used elsewhere in the project.
  • typeof together with string interpolation lets you compose string literal types from runtime constants and other types.
  • Nesting works the same way you’re used to from CSS preprocessors.
  • Any selector supported by CSS can be used.
  • The plugin scans your TypeScript project and emits CSS file(s).
  • On edits, only the changed files are re-scanned.

Why Typique

Typique is built to feel boring — in a good way. No new runtime model, no clever indirections, just CSS generated directly from TypeScript with minimal friction.

  • No bundler hell — ever. Requires no extra bundler or framework configuration.
  • Fast by design. Reuses data TypeScript already computes for your editor.
  • Framework-agnostic. Works directly in TypeScript files; other file types can import styles from .ts.
  • Colocation by default. Define styles anywhere TypeScript allows — top level, inside functions, even inside loops.
  • Feels like real CSS. Supports natural nesting (compatible with the CSS Nesting spec) with a clean, object-based syntax.
  • Zero effort SSR / RSC. Works seamlessly because it emits plain CSS, not runtime code.
  • Transparent naming. Class and variable names stay readable, configurable, and visible in your source — no hidden magic.
  • Easy to migrate away. The generated CSS is clean, formatted, and source-ready.

Version requirements

  • TypeScript: 5.5 up to 6.0
    TypeScript-Go (7) is not supported yet because it does not currently expose a plugins API. Typique will support it once the API becomes available.
  • Node.js: 18 and above

Supported file types

A file type is supported if it is opened by the TypeScript server and contains TypeScript syntax.

  • Native support: .ts, .tsx, .mts
  • Vue: .vue files are supported when your IDE uses the official Vue TypeScript plugin (this is the default in VS Code).
    How does it work? The Vue TypeScript plugin intercepts file-open requests and transpiles .vue files into plain TypeScript syntax. This allows TypeScript — and custom plugins like Typique — to operate on them as if they were regular .ts files.
  • Not supported: .svelte and .js files. Styles can still be defined in TypeScript and imported from there.

Getting started

1. Install workspace TypeScript and Typique

Using workspace-scoped TypeScript plugins like Typique requires a workspace TypeScript. Both the typescript and typique packages must be installed in the same node_modules. To ensure this, run the npm i / pnpm add commands from the same directory — typically the project root:

npm i -D typescript
npm i typique

Or:

pnpm add -D typescript
pnpm add typique

If you use VS Code, switch to the workspace TypeScript: Command Palette → Select TypeScript Version → Use Workspace Version.

2. Add the plugin to tsconfig.json

{
  "compilerOptions": {
    "plugins": [
      {
        "name": "typique/ts-plugin"
      }
    ]
  }
}
How is the path typique/ts-plugin resolved?

The path (or name) is resolved from the node_modules directory where typescript is installed, regardless of the relative location of tsconfig.json. Since typique is installed in the same node_modules, the path is always the same: typique/ts-plugin.

Note: specifying a path here is only supported starting with TypeScript 5.5; earlier versions support only a pathless plugin name.

3. Write some styles

Name your constants ...Class and ...Var to instruct Typique to suggest completion items in the constant initializers. In WebStorm, you may need to invoke explicit completion (Ctrl+Space) to see the suggestions.

import type { Css, Var } from 'typique'

const sizeVar = '--size' satisfies Var
//              ^
// Completion appears here
const roundButtonClass = 'round-button' satisfies Css<{
  //                     ^
  //        Completion appears here
  [sizeVar]: 32
  borderRadius: `calc(${typeof sizeVar} / 2)`
  height: `var(${typeof sizeVar})`
  width: `var(${typeof sizeVar})`
}>

Full naming conventions are explained further.

The suggested class names are guaranteed to be unique within the TypeScript project. The scope of this uniqueness is explained in more detail below.

4. Import the generated CSS into your app

By default, Typique outputs a single CSS file named typique-output.css in your project root. Import it into your HTML template or entry point:

<html>
<head>
  ...
  <link href="./typique-output.css" rel="stylesheet">
</head>
...
</html>

The file name is configurable.

5. Add a build step

Run the following command to generate the CSS file from the command line:

npx typique --projectFile ./index.ts --tsserver ./path/to/tsserver.js -- ...ts-args
  • --projectFile (required) — any TypeScript file used to bootstrap the TypeScript project, for example your root component or application entry point.
  • --tsserver (optional) — the path to the TypeScript server executable. Defaults to the result of import.meta.resolve('typescript/lib/tsserver.js').
  • ...ts-args (optional) — any valid TypeScript server command-line arguments, such as logging or global plugins.
How can I specify a custom tsconfig.json?

Unlike tsc, tsserver.js does not allow explicitly specifying a custom tsconfig.json file. Instead, it locates the configuration internally when opening the file provided via --projectFile. This is usually the first tsconfig.json found when walking up the directory hierarchy from that file.

If you need to use a custom tsconfig.json, you can apply the following workaround:

  1. Temporarily replace the original tsconfig.json with your custom one.
  2. Run npx typique.
  3. Restore the original tsconfig.json.

Completion in different contexts

One of the core ideas of Typique as a tooling is to recognize when you are about to specify a class or CSS variable name and suggest a readable, unique name via code completion. A location where Typique expects a class or CSS variable name is called a completion context. Completion behaves slightly differently depending on the context.

In variable initializer

All of the examples above use this kind of context. Completion is suggested when the variable name matches a configurable pattern, which by default is:

  • Class(es)?([Nn]ame(s)?)?$ for class names
  • Var(s)?([Nn]ame(s)?)?$ for variable names

In TSX property value

This is useful for TSX-native frameworks such as React, Preact, SolidJS, Qwik, and others. Completion is shown in the value of a property whose name matches the configurable pattern, which by default is ^class(Name)?$:

export function Button() {
  return <button className={ 'button' satisfies Css<{
    //                        ^
    //           Completion appears here
    border: 'none'
    padding: `calc(${typeof unit}px * 2)`
  }> }>
    Click me
  </button>
}

In other contexts

In principle, you can manually add satisfies Css<...> or satisfies Var to any literal expression. This does not provide completion, but everything else — CSS generation and uniqueness checks — works the same way as in other contexts.

export function Button() {
  return `<button class="${ 'button' satisfies Css<...> }" />`
}

Context name

A context name is what Typique uses to generate class and css-var names. It is an internal identifier derived from the surrounding code.

const lgBtnClass = ''
//                 ^
// context name: lgBtn
function AppTitle() {
  return <h1 className={''} />
  //                    ^
  // context name: AppTitle/h1
}

The context name is very close to the variable name or TSX path, yet not identical:

  • For variables, it does not include the matched part of the naming convention (for example, the Class suffix).
  • For TSX, it does not include the prop name (className).

The context name defines which class/css-var names are suggested in this place. Actual names do not have to include the full context name. For example:

  • For lgBtn, possible names include: lg-btn, lg, btn, l-b, etc.
  • For AppTitle/h1, possible names include: app-title-h1, app-h1, etc.
  • For any context name, it's also allowed to have a numeric suffix (counter): -0, -1, -2 etc. Typique uses it when the name is already taken elsewhere.

Finally, the naming options let you control how the context name is transformed into a class or css-var name. For example, you can add constant parts, random parts, or even exclude the context name entirely.

Class and CSS variable name validation

Typique validates that a name:

  • Matches the context name and the current naming options
  • Is unique within the TypeScript project

If a name is invalid, a diagnostic is shown:

Name-related diagnostics are always accompanied by appropriate quick fixes:

The scope of name uniqueness

As a reminder, a TypeScript project consists of:

  • A tsconfig.json file
  • The source files included by that config (often called roots)
  • All files reachable via imports from roots

A single workspace can contain multiple TypeScript projects, which is common in monorepos.

Typique enforces name uniqueness at the TypeScript project level, not at the workspace level. To guarantee uniqueness across multiple TypeScript projects, you can add prefixes or suffixes via the naming options. For more advanced setups, see the Monorepos and Shared Code guide.


The next several sections describe the syntax Typique supports for defining styles. They are mostly independent and can be read in any order.

Nesting CSS objects

Nested rules are interpreted according to the emerging CSS Nesting Module specification. Currently, Typique downlevels nested rules into plain CSS; support for native CSS nesting is planned.

const fancyClass = 'fancy' satisfies Css<{
  color: 'teal'
  '@media (max-width: 600px)': {
    color: 'cyan'
    '&:active': {
      color: 'magenta'
    }
  }
}>

Output:

.fancy {
  color: teal;
}
@media (max-width: 600px) {
  .fancy {
    color: cyan;
  }
  .fancy:active {
    color: magenta;
  }
}

Array notation

Allows defining multiple related CSS variables or class names in a single expression.

For CSS variables, make sure to add as const after the array initializer; otherwise, TypeScript will infer the type as string[]:

const [xVar, yVar] = ['--x', '--y'] as const satisfies Var
//                    ^
//      A two-place completion appears here

For styles, it’s possible to reference class names from the left-hand side using $0, $1, $2, and so on:

const [rootClass, largeClass, boldClass, smallClass] =
  ['root', 'large', 'bold', 'small'] satisfies Css<{
// ^
// A four-place completion appears here
    padding: '1rem' // root
    '&.$1': { // root.large
      padding: '1.3rem'
      '&.$2': { // root.large.bold
        fontWeight: '700'
      }
    }
    '&.$3': { // root.small
      padding: '0.5rem'
      '&.$2': { // root.small.bold
        fontWeight: '600'
      }
    }
  }>

Typique validates that all names are referenced and that all $ references are valid.

Keyframes, layers, and other identifiers

$ references can be used to reference any identifier, not just class names. This is useful for keyframes, layers, and similar constructs.

const [buttonClass] = ['button', 'cn-1'] satisfies Css<{
  //                   ^         ^
  //        Completion appears in both positions
  animation: '$1 0.3s ease-in-out'
  '@keyframes $1': {
    from: {
      opacity: 0
    }
    to: {
      opacity: 1
    }
  }
}>

The name 'cn-1' is derived from the configurable default context name and, like any other name, is guaranteed to be unique. If you need the generated keyframes name at runtime, you can request it on the left-hand side, as usual: const [buttonClass, fadeInKeyframes] = ....

Object notation

For CSS variables, object notation is useful for defining themes:

const themeVars = {
  bgColor: '--theme-bg-color',
  //       ^
  // Completion appears here and likewise below
  space: '--theme-space'
} as const satisfies Var

As with array notation, make sure to add as const after the object initializer; otherwise, TypeScript will infer the values as string.

For styles, this notation allows referencing class names via named $ references:

const buttonClasses = {
  _: 'button',
  // ^
  // Completion appears here and likewise below
  b: 'button-b',
  sz: {
    lg: 'button-sz-lg',
    sm: 'button-sz-sm',
  }
} satisfies Css<{
  padding: '1rem' // associated with the first classname, 'button'
  '&.$sz$lg': {   // button.button-sz-lg
    padding: '1.3rem'
    '&.$b': {     // button.button-sz-lg.button-b
      fontWeight: '700'
    }
  }
  '&.$sz$sm': {   // button.button-sz-sm
    padding: '0.5rem'
    '&.$b': {     // button.button-sz-sm.button-b
      fontWeight: '600'
    }
  }
}>

Object notation also enables selecting class names based on props in a declarative way using the co() function, explained below.

Global CSS

Styles that don’t contain non-object properties at the top level and don’t use $ references are emitted as-is, resulting in global CSS:

[] satisfies Css<{
  body {
    margin: 0
  }
  '.hidden': {
    display: 'none'
  }
  '@font-face': {
    fontFamily: 'Open Sans'
    src: 'open-sans.woff'
  }
}>

Typique outputs this CSS unchanged. You can also mix local and global class names:

const flexClass = 'flex-0' satisfies Css<{
  display: 'flex'
  '&.hidden': {
    display: 'none'
  }
}>

This outputs:

.flex-0 {
  display: flex;
}
.flex-0.hidden {
  display: none;
}

Fallbacks

Use tuple notation to assign multiple values to the same property.

const cClass = 'c' satisfies Css<{
  color: ['magenta', 'oklch(0.7 0.35 328)']
}>

Utils

Typique provides two utilities for combining class names: cc() and co(). Both are exported from the typique/util module.

cc() — concatenate class names

Simply concatenates all values that are truthy. This is useful for selecting class names based on conditions, for example, prop values.

<button className={ cc(
  'button' satisfies Css<{
    border: 'unset'
  }>,
  isLarge && 'button-0' satisfies Css<{
    fontSize: '1.4em'
  }>,
) }/>

co() — select class names from a class name object

Selects class names from a classesObject based on props. This utility is designed to work together with object notation.

<button className={ co(
  {size},
  {
    _: 'button',
    size: {
      small: 'button-size-small',
      large: 'button-size-large',
    },
  } satisfies Css<{
    border: 'unset'
    '.$size$small': {
      fontSize: '.8em'
    }
    '.$size$large': {
      fontSize: '1.4em'
    }
  }>,
) }/>

Based on the value of the size variable passed in the first object, the resulting class name will be either button button-size-small or button button-size-large. See the co() TSDoc for more examples.

TypeScript recipes

Here are some TypeScript recipes that come in handy with Typique.

typeof operator

Converts a constant to a literal type.

const unit = 4
const spacedClass = 'spaced' satisfies Css<{
  padding: typeof unit // Type is 4, rendered as 4px
}>

String interpolation

This works for both types and values.

const unit = 4
const padding = `${typeof unit}em` as const // Type is `4em`
type Padding = `${typeof unit}em`           // Type is `4em`

Note: the + operator produces the string and not a constant type. Make sure to always use the interpolation instead.

Computed properties

This is useful for assigning a value to a CSS variable.

const paddingVar = '--padding' satisfies Var
const spacedClass = 'spaced' satisfies Css<{
  [paddingVar]: 4
}>

Templating with generics

This is example of the dark theme which is by default synchronized with the system theme, but also can be overridden by user settings.

import type {Css, Var} from 'typique'

const [bgColorVar, nameVar] = ['--bgColor', '--name'] as const satisfies Var

type Light<Name extends string = '🖥️'> = {
  [bgColor]: '#fff'
  [name]: `"${Name}"`
}
type Dark<Name extends string = '🖥️'> = {
  [bgColor]: '#444'
  [name]: `"${Name}"`
}

const [lightClass, darkClass] = ['light', 'dark'] satisfies Css<{
  body: Light
  '@media (prefers-color-scheme: dark)': {
    body: Dark
  }
  'body.$0': Light<'☀️'>
  'body.$1': Dark<'🌙'>
}>

Intersection operator

This can be used to join multiple type objects:

type NoPaddingNoMargin = {
  padding: 0
  margin: 0
}

const buttonClass = 'button' satisfies Css<NoPaddingNoMargin & {
  // ...
}>

Mapped type

Can be used, for example, to define specs of multiple properties at once:

[] satisfies Css<{
  [_ in `@property ${typeof c0 | typeof c1}`]: {
    syntax: '"<color>"'
    initialValue: '#888'
    inherits: false
  }
}>

Further reading

  • Why Typique exists — background and context (DEV.to)
  • Demos — examples of using Typique with different frameworks, including configuring TypeScript in monorepos
  • Configuration — a complete reference of plugin settings
  • Monorepos and Shared Code — guidance on using Typique in monorepos and reusable libraries
  • Plugin Description — details on the plugin and package architecture and performance

Plans

Depending on community feedback, the project may evolve in the following directions:

  • CSS syntax highlighting and improved completion
  • Names defined purely in type space, without requiring runtime values
  • More flexible configuration of name prefixes and suffixes
  • Refactoring tools

Acknowledgements

Typique draws inspiration from the following great libraries:

About

Bundler-agnostic, zero-runtime CSS-in-TS — powered by a TypeScript plugin

Resources

License

Stars

Watchers

Forks

Packages

No packages published